diff --git a/docs/adr/ADR-046-depth-aware-cursor.md b/docs/adr/ADR-046-depth-aware-cursor.md index 6862a0825a..ed32c29711 100644 --- a/docs/adr/ADR-046-depth-aware-cursor.md +++ b/docs/adr/ADR-046-depth-aware-cursor.md @@ -606,7 +606,7 @@ sprite and the depth, and the runtime owns the drawing. Apps get the same contra kept, else the classic arrow (12 × 19, white fill, dark outline, rasterised procedurally, 4 × 4 supersampled). `DXR_CURSOR_DEFAULT_LOOK=arrow` forces the arrow. - **Not yet:** D3D11, D3D12 and the service (`svc_draw_cursor_quads`) report - `willDraw = XR_FALSE`; display zones; multi-screen routed frames draw per tile without the + `willDraw = XR_FALSE`; display zones (since drawn, runtime#1949, below); multi-screen routed frames draw per tile without the segment routing on Metal (Vulkan and GL skip them); the 0.5 px floor of A.3 is not applied (−0.005 is above it on current panels). @@ -669,6 +669,32 @@ sprite and the depth, and the runtime owns the drawing. Apps get the same contra - **Found in passing (Metal, #1941):** on the Vulkan-app-on-Metal fallback a custom sprite is handed over as the client swapchain and cast to the native Metal type — runtime#1948. +#### §B as built in display-zones frames (`XR_DXR_cursor_depth` spec v6, unchanged; runtime#1949) + +- **One cursor per frame, in the zone under the pointer.** The hint already rides the + zone-scoped locate of that zone (§2.1 of the spec): the zone rect is that locate's canvas, so + `cursorUV`, the placement and its *unit* (zone height × t) are all zone-relative. The arming + locate now also records the `XrDisplayZoneDXR::zoneId` it carried. +- **State tracker.** In a zones frame `xrEndFrame` maps the placement through the views of the + frame's projection layer whose chained `XrDisplayZoneDXR` has that `zoneId` (its submitted + poses and fovs, same-space rule unchanged), so the map is ZONE-normalised; the request + (`u_cursor_draw_request`) gains `zone_valid` / `zone_id` / `zone_rect` (the rect as SUBMITTED — + authoritative per ADR-027). No such zone, a zone-scoped hint in a frame without zones, or a + zones frame whose hint was not zone-scoped: nothing drawn, `willDraw = XR_FALSE` on the next + locates, one-time WARN — the same OS-cursor fallback as before. No new struct, no spec bump: + `willDraw` already tells the app frame by frame. +- **Compositors (Metal, vk_native, GL).** One pure helper, `u_cursor_draw_into_zone()`, re-expresses + the zone-normalised views in the tile exactly the way each compositor places the zone's own + layer (zone rect scaled by tile / window), and returns the zone's sub-rect as a clip; the draw + is scissored to tile ∩ zone, so a cursor at a zone edge never bleeds into a 2D zone or a + neighbouring 3D zone. `u_cursor_draw_canvas_px_h()` sizes the sprite against the zone's height + (the canvas its unit was built for). A non-zone request is untouched (clip = whole tile). + Zero cost unchanged: all of it runs only on a requested frame. +- **D3D11 / D3D12** (draft runtime#1944) adopt the same two helpers at their draw site. +- **Reference app.** `cube_zones_{metal,vk}_macos` adopt `dxr::CursorDepth` via + `test_apps/common/cursor_depth_zones.h`: pick the topmost 3D zone under the pointer, hand the + helper the pointer in that zone's UV, chain the hint only on that zone's locate. + ### Rollout 1. **Groundwork:** diff --git a/docs/specs/extensions/XR_DXR_cursor_depth.md b/docs/specs/extensions/XR_DXR_cursor_depth.md index 51fccee69e..ac07f829fa 100644 --- a/docs/specs/extensions/XR_DXR_cursor_depth.md +++ b/docs/specs/extensions/XR_DXR_cursor_depth.md @@ -163,7 +163,15 @@ display processor, one sprite per view tile, depth test off, never occluded, alp Each view's copy is projected with **that view's submitted pose and fov from the frame's first projection layer**, so the projection layer must be in the **same `XrSpace`** as the locate (otherwise nothing is drawn and later locates report `willDraw = XR_FALSE` until a frame's layer -is in the locate space again). Display-zones frames are not drawn yet (same fallback). +is in the locate space again). + +**Display zones.** One cursor per frame, in the 3D zone whose zone-scoped locate carried the +hint (the zone under the pointer; `cursorUV` is relative to that zone). It is projected with +**the views of the projection layer chained with that zone's `XrDisplayZoneDXR` (same +`zoneId`)**, drawn only inside that zone's rect as submitted, never into a 2D zone or another +3D zone. If the frame has no 3D zone with that `zoneId`, the hint did not ride a zone-scoped +locate in a zones frame, or a zone-scoped hint arrives in a frame without zones, nothing is +drawn and later locates report `willDraw = XR_FALSE` (the fallback above). ### 2.6 `XrCursorDepthDrawStateDXR` (output, spec v5) @@ -171,7 +179,7 @@ Chained on `XrViewState::next`; filled on every locate that chains it. `willDraw only when this locate chained a hint carrying a visible `XrCursorDepthDrawInfoDXR`, the placement is active (the §3 policy: content pops out under the cursor), and the session's compositor can draw it. **Hide the OS cursor over the canvas exactly while `willDraw` is `XR_TRUE`.** Whenever -the runtime cannot draw (another backend, a space mismatch, a zones frame), it reports +the runtime cannot draw (another backend, a space mismatch, a zones frame without the hinted zone), it reports `XR_FALSE`, so the fallback is always the flat OS cursor, never no cursor. The runtime never touches the OS cursor itself. diff --git a/src/xrt/auxiliary/util/u_cursor_depth.c b/src/xrt/auxiliary/util/u_cursor_depth.c index d65157ed5e..69bdd9701f 100644 --- a/src/xrt/auxiliary/util/u_cursor_depth.c +++ b/src/xrt/auxiliary/util/u_cursor_depth.c @@ -534,6 +534,59 @@ u_cursor_draw_view_from_placement(const struct u_cursor_depth_view *view, return true; } +bool +u_cursor_draw_into_zone(struct u_cursor_draw_request *req, uint32_t target_w, uint32_t target_h, float out_clip[4]) +{ + out_clip[0] = 0.0f; + out_clip[1] = 0.0f; + out_clip[2] = 1.0f; + out_clip[3] = 1.0f; + if (!req->zone_valid) { + return true; + } + if (target_w == 0 || target_h == 0 || req->zone_rect.extent.w <= 0 || req->zone_rect.extent.h <= 0) { + return false; + } + // The zone's sub-rect of the tile, exactly as its layer is placed. + const float zx = (float)req->zone_rect.offset.w / (float)target_w; + const float zy = (float)req->zone_rect.offset.h / (float)target_h; + const float zw = (float)req->zone_rect.extent.w / (float)target_w; + const float zh = (float)req->zone_rect.extent.h / (float)target_h; + + const float x0 = zx > 0.0f ? zx : 0.0f; + const float y0 = zy > 0.0f ? zy : 0.0f; + const float x1 = zx + zw < 1.0f ? zx + zw : 1.0f; + const float y1 = zy + zh < 1.0f ? zy + zh : 1.0f; + if (!(x1 > x0) || !(y1 > y0)) { + return false; + } + + const uint32_t n = req->view_count < XRT_MAX_VIEWS ? req->view_count : XRT_MAX_VIEWS; + for (uint32_t i = 0; i < n; i++) { + struct u_cursor_draw_view *v = &req->view[i]; + v->origin[0] = zx + v->origin[0] * zw; + v->origin[1] = zy + v->origin[1] * zh; + v->axis_x[0] *= zw; + v->axis_x[1] *= zh; + v->axis_y[0] *= zw; + v->axis_y[1] *= zh; + } + out_clip[0] = x0; + out_clip[1] = y0; + out_clip[2] = x1 - x0; + out_clip[3] = y1 - y0; + return true; +} + +uint32_t +u_cursor_draw_canvas_px_h(const struct u_cursor_draw_request *req, uint32_t canvas_px_h) +{ + if (req->zone_valid && req->zone_rect.extent.h > 0) { + return (uint32_t)req->zone_rect.extent.h; + } + return canvas_px_h; +} + void u_cursor_draw_corners( const struct u_cursor_draw_view *v, float height, float aspect, float hot_u, float hot_v, float out_xy[4][2]) diff --git a/src/xrt/auxiliary/util/u_cursor_depth.h b/src/xrt/auxiliary/util/u_cursor_depth.h index 136ba11e22..a6f5d8a019 100644 --- a/src/xrt/auxiliary/util/u_cursor_depth.h +++ b/src/xrt/auxiliary/util/u_cursor_depth.h @@ -490,6 +490,21 @@ struct u_cursor_draw_request float hot_u, hot_v; //! Sprite HEIGHT on the panel, metres; <= 0 = the OS cursor's size. float size_m; + + /*! + * XR_DXR_display_zones (#1949): the hint rode the zone-scoped locate of + * this 3D zone, and the views above are that zone's submitted views - so + * they are ZONE-normalised (0..1 spans the zone's sub-rect of each tile) + * and one unit is the ZONE's height. The compositor re-expresses them in + * its tile with u_cursor_draw_into_zone() and clips the draw to the zone. + * False (zero-initialised) = the whole tile, as before. + */ + bool zone_valid; + //! The app's zone id (debug / logs only). + uint32_t zone_id; + //! The zone rect as submitted, client-window pixels, origin top-left - + //! the same space every compositor places XRT_LAYER_ZONE_3D layers in. + struct xrt_rect zone_rect; }; /*! @@ -530,6 +545,33 @@ void u_cursor_draw_corners( const struct u_cursor_draw_view *v, float height, float aspect, float hot_u, float hot_v, float out_xy[4][2]); +/*! + * XR_DXR_display_zones (#1949): re-express a zone request in TILE-normalised + * coordinates, the way the compositor places the zone's own layer - the zone + * rect scaled by tile / @p target (the window the zone rects are authored + * against; in a zones frame the tile spans it). + * + * Rewrites every view of @p req in place (origin into the zone's sub-rect, + * axes scaled by its extent) and returns the sub-rect, clipped to the tile, in + * @p out_clip (x, y, w, h; tile-normalised, origin top-left): the draw must be + * scissored to it, so a cursor at a zone edge never spills into a 2D zone or + * a neighbouring 3D zone. A request without a zone is left untouched and + * clips to the whole tile. + * + * @return false if the zone cannot be placed (no target size, or the zone lies + * entirely outside the window): draw nothing. + */ +bool +u_cursor_draw_into_zone(struct u_cursor_draw_request *req, uint32_t target_w, uint32_t target_h, float out_clip[4]); + +/*! + * The height, in the compositor's target pixels, of the canvas the request's + * unit was built for: the zone's height for a zone request, else + * @p canvas_px_h (the window / canvas the compositor already uses). + */ +uint32_t +u_cursor_draw_canvas_px_h(const struct u_cursor_draw_request *req, uint32_t canvas_px_h); + /*! * Rasterise the classic arrow (white fill, dark one-px outline, tip at the * top-left corner) into a @p w x @p h RGBA8 image with PREMULTIPLIED alpha. diff --git a/src/xrt/compositor/gl/comp_gl_compositor.cpp b/src/xrt/compositor/gl/comp_gl_compositor.cpp index 450a99af51..1c3ad8732f 100644 --- a/src/xrt/compositor/gl/comp_gl_compositor.cpp +++ b/src/xrt/compositor/gl/comp_gl_compositor.cpp @@ -6053,8 +6053,19 @@ gl_cursor_update_default(struct comp_gl_compositor *c, float backing_scale) * target). */ static bool -gl_cursor_draw(struct comp_gl_compositor *c, const struct u_cursor_draw_request *req) +gl_cursor_draw(struct comp_gl_compositor *c, + const struct u_cursor_draw_request *req_in, + uint32_t zones_target_w, + uint32_t zones_target_h) { + // #1949: a zone request's views are zone-normalised - re-express them in + // the tile the way the zone's own layer is placed, and clip to the zone. + struct u_cursor_draw_request zreq = *req_in; + const struct u_cursor_draw_request *req = &zreq; + float clip[4]; + if (!u_cursor_draw_into_zone(&zreq, zones_target_w, zones_target_h, clip)) { + return false; + } if (!gl_cursor_ensure_program(c)) { return false; } @@ -6074,6 +6085,7 @@ gl_cursor_draw(struct comp_gl_compositor *c, const struct u_cursor_draw_request if (canvas_px_h == 0) { canvas_px_h = c->eff_rows * c->eff_tile_h; } + canvas_px_h = u_cursor_draw_canvas_px_h(req, canvas_px_h); // a zone's own height if (canvas_px_h == 0) { return false; } @@ -6156,7 +6168,16 @@ gl_cursor_draw(struct comp_gl_compositor *c, const struct u_cursor_draw_request bh = c->eff_tile_h; } glViewport((GLint)bx, (GLint)by, (GLsizei)bw, (GLsizei)bh); - glScissor((GLint)bx, (GLint)by, (GLsizei)bw, (GLsizei)bh); + // Clipped to the zone in a zones frame (#1949): the clip is + // top-left-origin, the tile box bottom-left. + const GLint sx0 = (GLint)bx + (GLint)lroundf(clip[0] * (float)bw); + const GLint sx1 = (GLint)bx + (GLint)lroundf((clip[0] + clip[2]) * (float)bw); + const GLint sy0 = (GLint)by + (GLint)lroundf((1.0f - clip[1] - clip[3]) * (float)bh); + const GLint sy1 = (GLint)by + (GLint)lroundf((1.0f - clip[1]) * (float)bh); + if (sx1 <= sx0 || sy1 <= sy0) { + continue; + } + glScissor(sx0, sy0, (GLsizei)(sx1 - sx0), (GLsizei)(sy1 - sy0)); float xy[4][2]; u_cursor_draw_corners(&req->view[i], height, aspect, hot_u, hot_v, xy); @@ -6238,8 +6259,7 @@ gl_compositor_layer_commit_locked(struct xrt_compositor *xc, xrt_graphics_sync_h /* * XR_DXR_cursor_depth v5 (ADR-046 Amendment 2 B). ZERO COST UNLESS * REQUESTED: one field test, false for every session that never chained - * XrCursorDepthDrawInfoDXR. The request covers this commit only. Zones - * frames are refused by the state tracker already; belt and braces. + * XrCursorDepthDrawInfoDXR. The request covers this commit only. */ struct u_cursor_draw_request cursor_req = {}; bool cursor_pending = false; @@ -6247,7 +6267,9 @@ gl_compositor_layer_commit_locked(struct xrt_compositor *xc, xrt_graphics_sync_h if (c->cursor_draw.req.requested) { cursor_req = c->cursor_draw.req; c->cursor_draw.req.requested = false; - cursor_pending = !c->zones_frame && u_cursor_draw_should_draw(&cursor_req); + // A zones frame draws only a zone request (#1949; the state tracker + // sends nothing else there) and vice versa; belt and braces. + cursor_pending = c->zones_frame == cursor_req.zone_valid && u_cursor_draw_should_draw(&cursor_req); c->cursor_draw.pending = cursor_pending; c->cursor_draw.pending_source_srgb = cursor_req.image == NULL || gl_swapchain_is_srgb(gl_swapchain(cursor_req.image)); @@ -7086,7 +7108,7 @@ gl_compositor_layer_commit_locked(struct xrt_compositor *xc, xrt_graphics_sync_h #ifdef XRT_OS_WINDOWS cursor_routed = seg_routed_frame; #endif - if (cursor_pending && !cursor_routed && gl_cursor_draw(c, &cursor_req)) { + if (cursor_pending && !cursor_routed && gl_cursor_draw(c, &cursor_req, zones_target_w, zones_target_h)) { window_space_drew = true; // republish the compose target } diff --git a/src/xrt/compositor/metal/comp_metal_compositor.m b/src/xrt/compositor/metal/comp_metal_compositor.m index 75a8fe3b51..fe7510b7a5 100644 --- a/src/xrt/compositor/metal/comp_metal_compositor.m +++ b/src/xrt/compositor/metal/comp_metal_compositor.m @@ -4134,8 +4134,20 @@ - (BOOL)wantsUpdateLayer uint32_t atlas_view_w, uint32_t atlas_view_h, bool compose_linear, - uint32_t canvas_px_h) + uint32_t canvas_px_h, + uint32_t zones_target_w, + uint32_t zones_target_h) { + // #1949: a zone request's views are zone-normalised - re-express them in + // the tile the way the zone's own layer is placed, and clip to the zone. + struct u_cursor_draw_request zreq = *req; + float clip[4]; + if (!u_cursor_draw_into_zone(&zreq, zones_target_w, zones_target_h, clip)) { + return; + } + req = &zreq; + canvas_px_h = u_cursor_draw_canvas_px_h(req, canvas_px_h); + if (!metal_cursor_ensure_pipelines(c)) { return; } @@ -4209,9 +4221,20 @@ - (BOOL)wantsUpdateLayer MTLViewport vp = {(double)ox, (double)oy, (double)atlas_view_w, (double)atlas_view_h, 0.0, 1.0}; [encoder setViewport:vp]; // The scissor is mandatory: a cursor at a tile edge must not spill - // into the neighbour view (the DP would weave it into the wrong eye). - MTLScissorRect sr = {ox, oy, (ox + atlas_view_w > att_w) ? (att_w - ox) : atlas_view_w, - (oy + atlas_view_h > att_h) ? (att_h - oy) : atlas_view_h}; + // into the neighbour view (the DP would weave it into the wrong eye), + // nor - in a zones frame - out of its zone (#1949). + const uint32_t tw = (ox + atlas_view_w > att_w) ? (att_w - ox) : atlas_view_w; + const uint32_t th = (oy + atlas_view_h > att_h) ? (att_h - oy) : atlas_view_h; + const uint32_t cx0 = (uint32_t)lroundf(clip[0] * (float)atlas_view_w); + const uint32_t cy0 = (uint32_t)lroundf(clip[1] * (float)atlas_view_h); + uint32_t cx1 = (uint32_t)lroundf((clip[0] + clip[2]) * (float)atlas_view_w); + uint32_t cy1 = (uint32_t)lroundf((clip[1] + clip[3]) * (float)atlas_view_h); + cx1 = cx1 < tw ? cx1 : tw; + cy1 = cy1 < th ? cy1 : th; + if (cx1 <= cx0 || cy1 <= cy0) { + continue; + } + MTLScissorRect sr = {ox + cx0, oy + cy0, cx1 - cx0, cy1 - cy0}; [encoder setScissorRect:sr]; float xy[4][2]; @@ -5392,11 +5415,13 @@ - (BOOL)wantsUpdateLayer // never occluded - and before the display processor. Inside this // encoder, so the /tmp/dxr_atlas_trigger dump below shows it. if (cursor_pending) { - if (zones_frame) { - // The state tracker already refuses zones frames; belt and braces. + // A zones frame draws only a zone request (the state tracker + // sends nothing else there) and vice versa; belt and braces. + if (zones_frame != cursor_req.zone_valid) { if (!c->cursor_draw.warned_zones) { c->cursor_draw.warned_zones = true; - U_LOG_W("cursor_draw: zones frame - cursor not drawn (one-time warning)"); + U_LOG_W("cursor_draw: zones frame / zone request mismatch - cursor not drawn " + "(one-time warning)"); } } else { uint32_t canvas_px_h = 0; @@ -5406,7 +5431,7 @@ - (BOOL)wantsUpdateLayer canvas_px_h = (uint32_t)output_texture.height; } metal_cursor_draw(c, encoder, &cursor_req, atlas_cols, atlas_rows, atlas_view_w, atlas_view_h, - compose_linear, canvas_px_h); + compose_linear, canvas_px_h, zones_target_w, zones_target_h); } } diff --git a/src/xrt/compositor/vk_native/comp_vk_native_compositor.c b/src/xrt/compositor/vk_native/comp_vk_native_compositor.c index 60aee1a127..3b5e385fef 100644 --- a/src/xrt/compositor/vk_native/comp_vk_native_compositor.c +++ b/src/xrt/compositor/vk_native/comp_vk_native_compositor.c @@ -9449,24 +9449,39 @@ vk_shared_submit_and_wait(struct comp_vk_native_compositor *c, const VkSubmitInf * per-view placement into this frame's renderer cursor - which image, its * size in canvas units, and each view's corners in its tile. * + * @param target_w The target (window) width in backing pixels - with @p canvas_px_h, what + * a zone rect is scaled against (#1949). * @param canvas_px_h The canvas (window) height in backing pixels: the size * the Kooima views were built for, so one unit = canvas height. * @return false if nothing can be drawn this frame (the frame is untouched). */ static bool vk_cursor_resolve(struct comp_vk_native_compositor *c, - const struct u_cursor_draw_request *req, + const struct u_cursor_draw_request *req_in, + uint32_t target_w, uint32_t canvas_px_h, struct comp_vk_native_cursor_draw *out) { - if (c->zones_frame) { - // The state tracker already refuses zones frames; belt and braces. + // A zones frame draws only a zone request (the state tracker sends + // nothing else there) and vice versa; belt and braces. + if (c->zones_frame != req_in->zone_valid) { if (!c->cursor_draw.warned_zones) { c->cursor_draw.warned_zones = true; - U_LOG_W("cursor_draw: zones frame - cursor not drawn (one-time warning)"); + U_LOG_W( + "cursor_draw: zones frame / zone request mismatch - cursor not drawn (one-time warning)"); } return false; } + // #1949: a zone request's views are zone-normalised - re-express them in + // the tile the way the zone's own layer is placed (zone rect scaled by + // tile / target, the renderer's zone pass), and clip to the zone. + struct u_cursor_draw_request zreq = *req_in; + const struct u_cursor_draw_request *req = &zreq; + float clip[4]; + if (!u_cursor_draw_into_zone(&zreq, target_w, canvas_px_h, clip)) { + return false; + } + canvas_px_h = u_cursor_draw_canvas_px_h(req, canvas_px_h); if (c->eff_layout.route_count > 0) { // Multi-screen M3: a tile is a per-segment mosaic, not one view. if (!c->cursor_draw.warned_routed) { @@ -9521,6 +9536,7 @@ vk_cursor_resolve(struct comp_vk_native_compositor *c, } const float height = size_m / canvas_h_m; // sprite height, in units + memcpy(out->clip, clip, sizeof(out->clip)); out->view_count = req->view_count < XRT_MAX_VIEWS ? req->view_count : XRT_MAX_VIEWS; for (uint32_t i = 0; i < out->view_count; i++) { u_cursor_draw_corners(&req->view[i], height, aspect, hot_u, hot_v, out->corners[i]); @@ -9835,7 +9851,7 @@ vk_compositor_layer_commit_locked(struct xrt_compositor *xc, struct u_cursor_draw_request cursor_req = c->cursor_draw.req; c->cursor_draw.req.requested = false; if (u_cursor_draw_should_draw(&cursor_req)) { - (void)vk_cursor_resolve(c, &cursor_req, tgt_height, &cursor_draw); + (void)vk_cursor_resolve(c, &cursor_req, tgt_width, tgt_height, &cursor_draw); } } diff --git a/src/xrt/compositor/vk_native/comp_vk_native_renderer.c b/src/xrt/compositor/vk_native/comp_vk_native_renderer.c index 9d62d3301a..175f508d16 100644 --- a/src/xrt/compositor/vk_native/comp_vk_native_renderer.c +++ b/src/xrt/compositor/vk_native/comp_vk_native_renderer.c @@ -2096,12 +2096,26 @@ cursor_record_draws(struct comp_vk_native_renderer *r, } VkViewport vp = {.x = bx, .y = by, .width = bw, .height = bh, .minDepth = 0.0f, .maxDepth = 1.0f}; // The scissor is mandatory: a cursor at a tile edge must not spill - // into the neighbour view (the DP would weave it into the wrong eye). - const float sx1 = bx + bw > (float)r->atlas_alloc_width ? (float)r->atlas_alloc_width : bx + bw; - const float sy1 = by + bh > (float)r->atlas_alloc_height ? (float)r->atlas_alloc_height : by + bh; + // into the neighbour view (the DP would weave it into the wrong eye), + // nor - in a zones frame - out of its zone (#1949). + const float cx0 = bx + cur->clip[0] * bw; + const float cy0 = by + cur->clip[1] * bh; + float sx1 = cx0 + cur->clip[2] * bw; + float sy1 = cy0 + cur->clip[3] * bh; + sx1 = sx1 > bx + bw ? bx + bw : sx1; + sy1 = sy1 > by + bh ? by + bh : sy1; + sx1 = sx1 > (float)r->atlas_alloc_width ? (float)r->atlas_alloc_width : sx1; + sy1 = sy1 > (float)r->atlas_alloc_height ? (float)r->atlas_alloc_height : sy1; + const int32_t sx0i = (int32_t)(cx0 + 0.5f); + const int32_t sy0i = (int32_t)(cy0 + 0.5f); + const int32_t sx1i = (int32_t)(sx1 + 0.5f); + const int32_t sy1i = (int32_t)(sy1 + 0.5f); + if (sx1i <= sx0i || sy1i <= sy0i) { + continue; + } VkRect2D scissor = { - .offset = {(int32_t)bx, (int32_t)by}, - .extent = {(uint32_t)(sx1 - bx), (uint32_t)(sy1 - by)}, + .offset = {sx0i, sy0i}, + .extent = {(uint32_t)(sx1i - sx0i), (uint32_t)(sy1i - sy0i)}, }; const float(*c)[2] = cur->corners[i]; @@ -2679,9 +2693,9 @@ draw_zones_pass(struct comp_vk_native_renderer *r, // XR_DXR_cursor_depth v5 (ADR-046 Amendment 2 B): the runtime-drawn // cursor, LAST in the pass - over every layer it composed, never - // occluded - and before the atlas is published to the DP. Zones frames - // are refused upstream (the state tracker) and here. - if (cursor_ok && !zones_frame) { + // occluded - and before the atlas is published to the DP. In a zones + // frame it is clipped to the hinted zone (#1949; resolved upstream). + if (cursor_ok) { cursor_record_draws(r, cmd, cursor, cursor_view, cursor_sampler, layout); } diff --git a/src/xrt/compositor/vk_native/comp_vk_native_renderer.h b/src/xrt/compositor/vk_native/comp_vk_native_renderer.h index 435fa98584..806c656576 100644 --- a/src/xrt/compositor/vk_native/comp_vk_native_renderer.h +++ b/src/xrt/compositor/vk_native/comp_vk_native_renderer.h @@ -355,6 +355,12 @@ struct comp_vk_native_cursor_draw * u_cursor_draw_corners() output. */ float corners[XRT_MAX_VIEWS][4][2]; + /*! + * The draw's clip in each tile, tile-normalised (x, y, w, h; origin + * top-left): the whole tile, or a zone's sub-rect in a zones frame (#1949) + * so the cursor never spills out of its zone. + */ + float clip[4]; //! The app's sprite swapchain (sampled at @ref image_index, array layer //! 0) or NULL for the renderer's default-look image. struct xrt_swapchain *xsc; diff --git a/src/xrt/state_trackers/oxr/oxr_objects.h b/src/xrt/state_trackers/oxr/oxr_objects.h index b87d058751..9f688f6b7c 100644 --- a/src/xrt/state_trackers/oxr/oxr_objects.h +++ b/src/xrt/state_trackers/oxr/oxr_objects.h @@ -3185,8 +3185,14 @@ struct oxr_session //! The app's sprite, or NULL for the default look. struct oxr_swapchain *image; float hot_u, hot_v, size_m; + //! XR_DXR_display_zones (#1949): the arming locate was zone-scoped + //! (the hint rode the locate of the zone under the pointer); the + //! frame's zone layer with @ref zone_id carries the views to draw with. + bool zone_valid; + uint32_t zone_id; //! The last armed frame could not draw (projection layer in another - //! space, a zones frame, no projection layer): locates in the same + //! space, a zones frame without the hinted zone, no projection + //! layer): locates in the same //! space report willDraw = false, and keep arming so xrEndFrame can //! clear this once a frame is drawable again. bool cannot_draw; @@ -3195,6 +3201,7 @@ struct oxr_session bool warned_space; //!< one-shot WARN: layer space != locate space bool warned_no_layer; //!< one-shot WARN: armed frame had no projection layer bool warned_image; //!< one-shot WARN: sprite swapchain unusable + bool warned_zone; //!< one-shot WARN: zones frame without the hinted zone } cursor_draw; #endif diff --git a/src/xrt/state_trackers/oxr/oxr_session.c b/src/xrt/state_trackers/oxr/oxr_session.c index 8104029b6c..36537e5090 100644 --- a/src/xrt/state_trackers/oxr/oxr_session.c +++ b/src/xrt/state_trackers/oxr/oxr_session.c @@ -2550,6 +2550,20 @@ oxr_session_arm_cursor_draw(struct oxr_session *sess, sess->cursor_draw.hot_u = draw_info->hotSpot.x; sess->cursor_draw.hot_v = draw_info->hotSpot.y; sess->cursor_draw.size_m = isfinite(draw_info->sizeMeters) ? draw_info->sizeMeters : 0.0f; + // #1949: a zone-scoped locate framed its views (and cursorUV, and the + // unit above) to that zone; xrEndFrame draws through the zone's layer. + sess->cursor_draw.zone_valid = false; + sess->cursor_draw.zone_id = 0; +#ifdef OXR_HAVE_DXR_display_zones + if (sess->sys->inst->extensions.DXR_display_zones) { + const XrDisplayZoneDXR *zone = + OXR_GET_INPUT_FROM_CHAIN(viewLocateInfo, XR_TYPE_DISPLAY_ZONE_DXR, XrDisplayZoneDXR); + if (zone != NULL && zone->rect.extent.width > 0 && zone->rect.extent.height > 0) { + sess->cursor_draw.zone_valid = true; + sess->cursor_draw.zone_id = zone->zoneId; + } + } +#endif if (draw_state != NULL) { draw_state->willDraw = will_draw ? XR_TRUE : XR_FALSE; } diff --git a/src/xrt/state_trackers/oxr/oxr_session_frame_end.c b/src/xrt/state_trackers/oxr/oxr_session_frame_end.c index 4e55a5422c..0920eb7ae6 100644 --- a/src/xrt/state_trackers/oxr/oxr_session_frame_end.c +++ b/src/xrt/state_trackers/oxr/oxr_session_frame_end.c @@ -1976,6 +1976,12 @@ cursor_depth_record_layer(struct oxr_session *sess, const XrCompositionLayerProj * in the layer's own space, which must be the locate space - and hand the * compositor its one-frame draw request. * + * Display zones (#1949): one cursor per frame, in the zone whose locate + * carried the hint (the zone under the pointer). In a zones frame the views + * are THAT zone's layer's, so the map is zone-normalised; the request carries + * the zone rect as submitted and the compositor places it the way it places + * the zone's own layer. + * * Only ever called on an ARMED frame (the caller's single bool test), so a * session that never chained XrCursorDepthDrawInfoDXR never gets here. */ @@ -1985,22 +1991,64 @@ cursor_draw_submit(struct oxr_session *sess, const XrFrameEndInfo *frameEndInfo, sess->cursor_draw.armed = false; const XrCompositionLayerProjection *proj = NULL; - for (uint32_t i = 0; i < frameEndInfo->layerCount; i++) { - if (frameEndInfo->layers[i]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) { - proj = (const XrCompositionLayerProjection *)frameEndInfo->layers[i]; - break; + struct xrt_rect zone_rect = {{0, 0}, {0, 0}}; + bool zone_missing = false; + if (zones_frame || sess->cursor_draw.zone_valid) { + // The hinted zone's layer (a zone locate without a zones frame, or + // a zones frame whose hint was not zone-scoped, has none). + zone_missing = true; +#ifdef OXR_HAVE_DXR_display_zones + for (uint32_t i = 0; zones_frame && sess->cursor_draw.zone_valid && i < frameEndInfo->layerCount; i++) { + if (frameEndInfo->layers[i]->type != XR_TYPE_COMPOSITION_LAYER_PROJECTION) { + continue; + } + const XrCompositionLayerProjection *p = + (const XrCompositionLayerProjection *)frameEndInfo->layers[i]; + const XrDisplayZoneDXR *zone = + OXR_GET_INPUT_FROM_CHAIN(p, XR_TYPE_DISPLAY_ZONE_DXR, XrDisplayZoneDXR); + if (zone != NULL && zone->zoneId == sess->cursor_draw.zone_id) { + proj = p; + // The submitted rect is authoritative (ADR-027): it is + // where the compositor places this zone's tiles. + zone_rect.offset.w = zone->rect.offset.x; + zone_rect.offset.h = zone->rect.offset.y; + zone_rect.extent.w = zone->rect.extent.width; + zone_rect.extent.h = zone->rect.extent.height; + zone_missing = false; + break; + } + } +#endif + } else { + for (uint32_t i = 0; i < frameEndInfo->layerCount; i++) { + if (frameEndInfo->layers[i]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) { + proj = (const XrCompositionLayerProjection *)frameEndInfo->layers[i]; + break; + } } } - // A zones frame places each zone's views at a sub-rect of the tile; the - // draw maps into whole tiles, so zones are not drawn yet (follow-up). - if (proj == NULL || zones_frame) { + if (zone_missing) { + sess->cursor_draw.cannot_draw = true; + if (!sess->cursor_draw.warned_zone) { + sess->cursor_draw.warned_zone = true; + U_LOG_W( + "XR_DXR_cursor_depth: runtime-drawn cursor requested, but %s - not drawn, reporting " + "willDraw = false (one-time warning)", + !zones_frame ? "the hint rode a zone-scoped locate and the frame has no display zones" + : !sess->cursor_draw.zone_valid + ? "the frame has display zones and the hint did not ride a zone-scoped locate" + : "the frame has no 3D zone with the hinted locate's zoneId"); + } + return; + } + if (proj == NULL) { sess->cursor_draw.cannot_draw = true; if (!sess->cursor_draw.warned_no_layer) { sess->cursor_draw.warned_no_layer = true; U_LOG_W( - "XR_DXR_cursor_depth: runtime-drawn cursor requested, but the frame has %s - not drawn, " - "reporting willDraw = false (one-time warning)", - zones_frame ? "display zones (not supported yet)" : "no projection layer to draw it with"); + "XR_DXR_cursor_depth: runtime-drawn cursor requested, but the frame has no projection " + "layer " + "to draw it with - not drawn, reporting willDraw = false (one-time warning)"); } return; } @@ -2055,6 +2103,11 @@ cursor_draw_submit(struct oxr_session *sess, const XrFrameEndInfo *frameEndInfo, req.hot_u = sess->cursor_draw.hot_u; req.hot_v = sess->cursor_draw.hot_v; req.size_m = sess->cursor_draw.size_m; + if (zones_frame) { + req.zone_valid = true; + req.zone_id = sess->cursor_draw.zone_id; + req.zone_rect = zone_rect; + } if (!u_cursor_draw_should_draw(&req)) { return; } diff --git a/test_apps/common/cursor_depth_zones.h b/test_apps/common/cursor_depth_zones.h new file mode 100644 index 0000000000..a63d55cca6 --- /dev/null +++ b/test_apps/common/cursor_depth_zones.h @@ -0,0 +1,198 @@ +// Copyright 2026, DisplayXR +// SPDX-License-Identifier: BSL-1.0 +/*! + * @file + * @brief XR_DXR_cursor_depth on a display-zones app (runtime#1949) — the + * cube_zones_*_macos reference adoption. + * + * With display zones the cursor hint rides the ZONE-SCOPED locate of the zone + * under the pointer: that locate's canvas is the zone rect, so the hint's + * cursorUV is zone-normalised, and the runtime draws the cursor into that + * zone's tiles only (one cursor per frame). Everything else is + * displayxr-common's dxr::CursorDepth, exactly as in the cube_handle apps: + * the footprint hit test against the previous locate's views, the struct + * chain, and hiding the OS cursor exactly while the runtime reports willDraw. + * + * Per frame: + * begin(windowUV, rects) pick the topmost 3D zone under the pointer + * for each zone: chain(zi, ...) before its xrLocateViews (no-op unless picked) + * afterLocate(zi, views, n) + * end() pointer over no 3D zone: OS cursor back + * + * The pointer is the window's (macOS: the NSView's), or a scripted + * WINDOW-normalised UV from DISPLAYXR_CURSOR_DEPTH_UV=u,v for headless + * verification. Opt out with DISPLAYXR_CURSOR_DEPTH=0. + */ + +#pragma once + +#include +#include + +#include "cursor_depth.h" // dxr::CursorDepth (displayxr-common >= v2.29.0) + +#include +#include +#include +#include + +#ifdef __OBJC__ +#import +#endif + +namespace dxr_zones_cursor { + +/*! + * Slab test against the zone cube as the zone apps place it: centred at + * (0, size/2, 0), edge @p size, rotated @p rot about +Y. + */ +inline bool +rayHitCube(const XrVector3f &o, const XrVector3f &d, float size, float rot, float &tOut) +{ + const float c = cosf(rot), s = sinf(rot), h = size * 0.5f; + const float ox = o.x, oy = o.y - h, oz = o.z; + const float lo[3] = {c * ox - s * oz, oy, s * ox + c * oz}; + const float ld[3] = {c * d.x - s * d.z, d.y, s * d.x + c * d.z}; + float t0 = 0.0f, t1 = 1e30f; + for (int i = 0; i < 3; i++) { + if (fabsf(ld[i]) < 1e-9f) { + if (lo[i] < -h || lo[i] > h) return false; + continue; + } + float ta = (-h - lo[i]) / ld[i], tb = (h - lo[i]) / ld[i]; + if (ta > tb) std::swap(ta, tb); + if (ta > t0) t0 = ta; + if (tb < t1) t1 = tb; + if (t0 > t1) return false; + } + tOut = t0; + return true; +} + +class ZoneCursor +{ +public: + /*! + * @param spec the runtime's XR_DXR_cursor_depth spec version, 0 = off. + * @param nsView the canvas view (hides the OS cursor; reads the pointer). + * @param cubeSize the zone cube's edge (the ray-cast target). + */ + void init(uint32_t spec, void *nsView, float cubeSize) + { + m_cubeSize = cubeSize; + m_view = nsView; + m_cursor.enable(spec); + if (spec == 0) return; + m_cursor.setWindowCocoa(nsView); + m_cursor.setCursorHeight(0.04f); + m_cursor.setWantPlacement(true); + m_cursor.setRayCast([this](const dxr::CursorRay &r) -> std::optional { + float t; + return rayHitCube(r.origin, r.dir, m_cubeSize, m_rot, t) ? std::optional(t) : std::nullopt; + }); + if (const char *fx = getenv("DISPLAYXR_CURSOR_DEPTH_UV")) { + float u, v; + if (sscanf(fx, "%f,%f", &u, &v) == 2) { + m_scripted = true; + m_scriptedUV = {u, v}; + } + } + } + + bool enabled() const { return m_cursor.enabled(); } + + /*! + * Pick the topmost 3D zone (the last in layer order) under the pointer and + * hand dxr::CursorDepth the pointer in THAT zone's UV. + * + * @param rects the zones' rects, client-window (backing) pixels, in + * layer order. + * @param rots each zone's cube rotation (the ray-cast target). + */ + void begin(int32_t winW, int32_t winH, const XrRect2Di *rects, const float *rots, uint32_t count) + { + m_zone = -1; + if (!enabled() || winW <= 0 || winH <= 0) return; + XrVector2f uv{}; + if (!windowUV(uv)) return; + const float px = uv.x * (float)winW, py = uv.y * (float)winH; + for (int i = (int)count - 1; i >= 0; i--) { + const XrRect2Di &r = rects[i]; + if (r.extent.width <= 0 || r.extent.height <= 0) continue; + const float zu = (px - (float)r.offset.x) / (float)r.extent.width; + const float zv = (py - (float)r.offset.y) / (float)r.extent.height; + if (zu < 0.0f || zu > 1.0f || zv < 0.0f || zv > 1.0f) continue; + m_zone = i; + m_rot = rots[i]; + m_cursor.setPointerUV(zu, zv); + return; + } + } + + //! Before zone @p zi's xrLocateViews: the hint rides only the picked zone's. + void chain(uint32_t zi, XrViewLocateInfo &locateInfo, XrViewState &viewState) + { + if (enabled() && (int)zi == m_zone) m_cursor.chain(locateInfo, viewState); + } + + void afterLocate(uint32_t zi, const XrView *views, uint32_t activeViewCount) + { + if (!enabled() || (int)zi != m_zone) return; + m_cursor.afterLocate(views, activeViewCount); + logState(); + } + + //! After the frame's locates: over no 3D zone, the OS cursor is the cursor. + void end() + { + if (!enabled() || m_zone >= 0) return; + m_cursor.setPointerUV(-1.0f, -1.0f); // off every canvas: no hint + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO}; + XrViewState vs{XR_TYPE_VIEW_STATE}; + m_cursor.chain(li, vs); + m_cursor.afterLocate(nullptr, 0); + } + +private: + bool windowUV(XrVector2f &uv) const + { + if (m_scripted) { + uv = m_scriptedUV; + return true; + } +#ifdef __OBJC__ + NSView *view = (__bridge NSView *)m_view; + if (view == nil || view.window == nil) return false; + NSPoint p = [view convertPoint:[view.window mouseLocationOutsideOfEventStream] fromView:nil]; + const NSRect b = view.bounds; + return dxr::cursorCanvasUV(p.x, p.y, b.size.width, b.size.height, !view.isFlipped, uv); +#else + (void)uv; + return false; +#endif + } + + void logState() + { + const int will = m_cursor.drawState().willDraw == XR_TRUE ? 1 : 0; + if (will != m_lastWill && m_logged < 6) { + m_lastWill = will; + m_logged++; + fprintf(stderr, "[INFO] cursor_depth (zones): zone #%d uv=(%.3f,%.3f) hasContent=%d disparity=%.3f willDraw=%s\n", + m_zone, m_cursor.hint().cursorUV.x, m_cursor.hint().cursorUV.y, (int)m_cursor.hint().hasContent, + m_cursor.placement().disparity, will ? "TRUE" : "FALSE"); + } + } + + dxr::CursorDepth m_cursor; + void *m_view = nullptr; + float m_cubeSize = 0.06f; + float m_rot = 0.0f; + int m_zone = -1; + bool m_scripted = false; + XrVector2f m_scriptedUV{}; + int m_lastWill = -1; + int m_logged = 0; +}; + +} // namespace dxr_zones_cursor diff --git a/test_apps/handle/cube_zones_metal_macos/CMakeLists.txt b/test_apps/handle/cube_zones_metal_macos/CMakeLists.txt index 1da623c7da..e61fd145bf 100644 --- a/test_apps/handle/cube_zones_metal_macos/CMakeLists.txt +++ b/test_apps/handle/cube_zones_metal_macos/CMakeLists.txt @@ -64,7 +64,8 @@ FetchContent_Declare( # R8G8B8A8 (ADR-044 §7, displayxr-common#72) — the Metal compositor is # format-honest, so a UNORM HUD of CPU-rasterized (encoded) bytes would be # encoded twice. Same pin as the VK macOS cube apps. - GIT_TAG v2.27.0 + # v2.29.0: dxr::CursorDepth (common/cursor_depth.h) for the zones cursor (runtime#1949). + GIT_TAG v2.29.0 GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(displayxr_common) diff --git a/test_apps/handle/cube_zones_metal_macos/main.mm b/test_apps/handle/cube_zones_metal_macos/main.mm index 104f787ad7..4a7ef407b8 100644 --- a/test_apps/handle/cube_zones_metal_macos/main.mm +++ b/test_apps/handle/cube_zones_metal_macos/main.mm @@ -73,6 +73,7 @@ #include #include #include "dxr_view_config.h" // #1486 PRIMARY_MULTIVIEW_DXR opt-in +#include "cursor_depth_zones.h" // XR_DXR_cursor_depth on display zones (runtime#1949) // ============================================================================ // Logging @@ -955,6 +956,14 @@ static void RenderScene(MetalRenderer &r, id target, static const uint32_t kNumZones = 2; +// XR_DXR_cursor_depth (ADR-046, runtime#1949): the runtime draws the depth +// cursor in the 3D zone under the pointer. The hint rides that zone's +// zone-scoped locate (cursor_depth_zones.h). Opt out with DISPLAYXR_CURSOR_DEPTH=0; +// DISPLAYXR_CURSOR_DEPTH_UV=u,v scripts a WINDOW-normalised pointer. +static bool g_hasCursorDepthExt = false; +static uint32_t g_cursorDepthSpecVersion = 0; +static dxr_zones_cursor::ZoneCursor g_zoneCursor; + // Per-zone rig framing for the test: virtual display height in app units // (shared by both zones; the cube is 0.06 m tall). static const float kZoneVirtualDisplayHeight = 0.30f; @@ -1437,6 +1446,10 @@ static bool InitializeOpenXR(AppXrSession &app) app.hasViewRigExt = true; if (strcmp(e.extensionName, XR_DXR_DISPLAY_ZONES_EXTENSION_NAME) == 0) g_hasDisplayZonesExt = true; + if (strcmp(e.extensionName, XR_DXR_CURSOR_DEPTH_EXTENSION_NAME) == 0) { + g_hasCursorDepthExt = true; + g_cursorDepthSpecVersion = e.extensionVersion; + } } // XR_DXR_display_zones composes XR_DXR_local_3d_zone + XR_DXR_view_rig — @@ -1485,6 +1498,15 @@ static bool InitializeOpenXR(AppXrSession &app) if (g_hasDisplayZonesExt) { enabledExts.push_back(XR_DXR_DISPLAY_ZONES_EXTENSION_NAME); } + if (g_hasCursorDepthExt) { + const char *opt = getenv("DISPLAYXR_CURSOR_DEPTH"); + if (opt != nullptr && strcmp(opt, "0") == 0) { + g_hasCursorDepthExt = false; // opted out: the runtime does no cursor work + } else { + enabledExts.push_back(XR_DXR_CURSOR_DEPTH_EXTENSION_NAME); + } + } + LOG_INFO("XR_DXR_cursor_depth: %s (v%u)", g_hasCursorDepthExt ? "ENABLED" : "off", g_cursorDepthSpecVersion); XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO}; strncpy(createInfo.applicationInfo.applicationName, "MetalCubeZonesOpenXR", @@ -1659,6 +1681,7 @@ static bool CreateSession(AppXrSession &app, MetalRenderer &r) XR_CHECK(xrCreateSession(app.instance, &sessionInfo, &app.session)); LOG_INFO("Session created%s", app.hasCocoaWindowBinding ? " (with external window)" : ""); + g_zoneCursor.init(g_hasCursorDepthExt ? g_cursorDepthSpecVersion : 0, (__bridge void *)g_metalView, 0.06f); // XR_DXR_mcp_tools: declare identity + register agent tools. The appId // MUST match `id` in displayxr/cube_handle_metal_macos.displayxr.json @@ -2469,6 +2492,17 @@ static void RenderZonesFrame(AppXrSession &app, MetalRenderer &renderer, const X std::vector projViews[kNumZones]; uint32_t submitViewCounts[kNumZones] = {}; + // XR_DXR_cursor_depth (#1949): pick the topmost 3D zone under the pointer. + { + XrRect2Di rects[kNumZones]; + float rots[kNumZones]; + for (uint32_t zi = 0; zi < kNumZones; zi++) { + rects[zi] = g_zonesArr[zi].rect; + rots[zi] = renderer.cubeRotation + g_zonesArr[zi].spinPhase; + } + g_zoneCursor.begin((int32_t)g_windowW, (int32_t)g_windowH, rects, rots, kNumZones); + } + for (uint32_t zi = 0; zi < kNumZones; zi++) { DisplayZone &z = g_zonesArr[zi]; @@ -2494,7 +2528,13 @@ static void RenderZonesFrame(AppXrSession &app, MetalRenderer &renderer, const X uint32_t viewCountOutput = 0; XrView zoneViews[8]; for (uint32_t vi = 0; vi < 8; vi++) zoneViews[vi] = {XR_TYPE_VIEW}; + // XR_DXR_cursor_depth (#1949): the hint rides only the locate of the + // zone under the pointer; its canvas is the zone rect. + g_zoneCursor.chain(zi, locateInfo, viewState); XrResult lr = xrLocateViews(app.session, &locateInfo, &viewState, 8, &viewCountOutput, zoneViews); + if (XR_SUCCEEDED(lr)) { + g_zoneCursor.afterLocate(zi, zoneViews, viewCountOutput); + } if (XR_FAILED(lr) || viewCountOutput == 0) { static bool warned = false; if (!warned) { @@ -2559,6 +2599,8 @@ static void RenderZonesFrame(AppXrSession &app, MetalRenderer &renderer, const X xrReleaseSwapchainImage(z.swapchain, &ri); } + g_zoneCursor.end(); // pointer over no 3D zone: the OS cursor is the cursor + // Layer list: [projA (zone A chained), projB (zone B chained), strip]. XrCompositionLayerProjection projLayers[kNumZones]; XrCompositionLayerLocal2DDXR stripLayer = {(XrStructureType)XR_TYPE_COMPOSITION_LAYER_LOCAL_2D_DXR}; diff --git a/test_apps/handle/cube_zones_vk_macos/CMakeLists.txt b/test_apps/handle/cube_zones_vk_macos/CMakeLists.txt index 866e79204a..5edd930431 100644 --- a/test_apps/handle/cube_zones_vk_macos/CMakeLists.txt +++ b/test_apps/handle/cube_zones_vk_macos/CMakeLists.txt @@ -70,7 +70,8 @@ FetchContent_Declare( # v2.27.0: window-space / HUD swapchains prefer the _SRGB sibling of # R8G8B8A8 (dxr::ChooseWindowSpaceSwapchainFormat; ADR-044 §7, # displayxr-common#72) — a UNORM HUD of encoded bytes was encoded twice. - GIT_TAG v2.27.0 + # v2.29.0: dxr::CursorDepth (common/cursor_depth.h) for the zones cursor (runtime#1949). + GIT_TAG v2.29.0 GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(displayxr_common) diff --git a/test_apps/handle/cube_zones_vk_macos/main.mm b/test_apps/handle/cube_zones_vk_macos/main.mm index a987456f31..22a2f41c25 100644 --- a/test_apps/handle/cube_zones_vk_macos/main.mm +++ b/test_apps/handle/cube_zones_vk_macos/main.mm @@ -45,6 +45,7 @@ #include #include #include "dxr_view_config.h" // #1486 PRIMARY_MULTIVIEW_DXR opt-in +#include "cursor_depth_zones.h" // XR_DXR_cursor_depth on display zones (runtime#1949) #include #include @@ -101,6 +102,14 @@ // ============================================================================ static const uint32_t kNumZones = 2; + +// XR_DXR_cursor_depth (ADR-046, runtime#1949): the runtime draws the depth +// cursor in the 3D zone under the pointer. The hint rides that zone's +// zone-scoped locate (cursor_depth_zones.h). Opt out with DISPLAYXR_CURSOR_DEPTH=0; +// DISPLAYXR_CURSOR_DEPTH_UV=u,v scripts a WINDOW-normalised pointer. +static bool g_hasCursorDepthExt = false; +static uint32_t g_cursorDepthSpecVersion = 0; +static dxr_zones_cursor::ZoneCursor g_zoneCursor; static const float kZoneVirtualDisplayHeight = 0.30f; struct DisplayZone { @@ -2361,6 +2370,10 @@ static bool InitializeOpenXR(AppXrSession& xr) { if (strcmp(ext.extensionName, XR_DXR_DISPLAY_ZONES_EXTENSION_NAME) == 0) { g_hasDisplayZonesExt = true; } + if (strcmp(ext.extensionName, XR_DXR_CURSOR_DEPTH_EXTENSION_NAME) == 0) { + g_hasCursorDepthExt = true; + g_cursorDepthSpecVersion = ext.extensionVersion; + } } if (!hasVulkan) { @@ -2403,6 +2416,15 @@ static bool InitializeOpenXR(AppXrSession& xr) { if (g_hasDisplayZonesExt) { enabledExtensions.push_back(XR_DXR_DISPLAY_ZONES_EXTENSION_NAME); } + if (g_hasCursorDepthExt) { + const char *opt = getenv("DISPLAYXR_CURSOR_DEPTH"); + if (opt != nullptr && strcmp(opt, "0") == 0) { + g_hasCursorDepthExt = false; // opted out: the runtime does no cursor work + } else { + enabledExtensions.push_back(XR_DXR_CURSOR_DEPTH_EXTENSION_NAME); + } + } + LOG_INFO("XR_DXR_cursor_depth: %s (v%u)", g_hasCursorDepthExt ? "ENABLED" : "off", g_cursorDepthSpecVersion); XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO}; strncpy(createInfo.applicationInfo.applicationName, "VkCubeZonesMacOS", XR_MAX_APPLICATION_NAME_SIZE); @@ -2764,6 +2786,7 @@ static bool CreateSession(AppXrSession& xr, VkInstance vkInstance, VkPhysicalDev XR_CHECK(xrCreateSession(xr.instance, &sessionInfo, &xr.session)); LOG_INFO("Session created%s", xr.hasCocoaWindowBinding ? " (with external window)" : ""); + g_zoneCursor.init(g_hasCursorDepthExt ? g_cursorDepthSpecVersion : 0, (__bridge void *)g_metalView, 0.06f); // Enumerate available rendering modes and store names if (xr.pfnEnumerateDisplayRenderingModesEXT && xr.session != XR_NULL_HANDLE) { @@ -3479,6 +3502,17 @@ static void RenderZonesFrame(AppXrSession& xr, VkRenderer& renderer, const XrFra std::vector projViews[kNumZones]; uint32_t submitViewCounts[kNumZones] = {}; + // XR_DXR_cursor_depth (#1949): pick the topmost 3D zone under the pointer. + { + XrRect2Di rects[kNumZones]; + float rots[kNumZones]; + for (uint32_t zi = 0; zi < kNumZones; zi++) { + rects[zi] = g_zonesArr[zi].rect; + rots[zi] = renderer.cubeRotation + g_zonesArr[zi].spinPhase; + } + g_zoneCursor.begin((int32_t)g_windowW, (int32_t)g_windowH, rects, rots, kNumZones); + } + for (uint32_t zi = 0; zi < kNumZones; zi++) { DisplayZone& z = g_zonesArr[zi]; @@ -3504,7 +3538,13 @@ static void RenderZonesFrame(AppXrSession& xr, VkRenderer& renderer, const XrFra uint32_t viewCountOutput = 0; XrView zoneViews[8]; for (uint32_t vi = 0; vi < 8; vi++) zoneViews[vi] = {XR_TYPE_VIEW}; + // XR_DXR_cursor_depth (#1949): the hint rides only the locate of the + // zone under the pointer; its canvas is the zone rect. + g_zoneCursor.chain(zi, locateInfo, viewState); XrResult lr = xrLocateViews(xr.session, &locateInfo, &viewState, 8, &viewCountOutput, zoneViews); + if (XR_SUCCEEDED(lr)) { + g_zoneCursor.afterLocate(zi, zoneViews, viewCountOutput); + } if (XR_FAILED(lr) || viewCountOutput == 0) { static bool warned = false; if (!warned) { @@ -3570,6 +3610,8 @@ static void RenderZonesFrame(AppXrSession& xr, VkRenderer& renderer, const XrFra xrReleaseSwapchainImage(z.swapchain, &ri); } + g_zoneCursor.end(); // pointer over no 3D zone: the OS cursor is the cursor + // Layer list: [projA (zone A chained), projB (zone B chained), strip]. XrCompositionLayerProjection projLayers[kNumZones]; XrCompositionLayerLocal2DDXR stripLayer = {(XrStructureType)XR_TYPE_COMPOSITION_LAYER_LOCAL_2D_DXR}; diff --git a/tests/tests_aux_cursor_depth.cpp b/tests/tests_aux_cursor_depth.cpp index b1ce77eb17..dcca703751 100644 --- a/tests/tests_aux_cursor_depth.cpp +++ b/tests/tests_aux_cursor_depth.cpp @@ -1097,3 +1097,150 @@ TEST_CASE("cursor_draw: the classic arrow - dark outline, white fill, tip at the // Below the tail (arrow px 1, 18.5): empty. CHECK(px(4, 74)[3] == 0); } + +/* + * #1949: display zones. A zone-scoped locate frames its views to the zone rect + * (the rect IS the canvas), so the hint's cursorUV, the placement's unit and + * the per-view map are all ZONE-normalised. The compositor re-expresses the + * map in its tile with u_cursor_draw_into_zone(), exactly the way it places the + * zone's own layer (zone rect scaled by tile / window). + */ +namespace { + +// A window WIN_PX_W x WIN_PX_H px spanning the W x H canvas, and a zone in it. +constexpr uint32_t WIN_PX_W = 1500; +constexpr uint32_t WIN_PX_H = 1000; + +// Kooima view from a display-space eye onto the sub-rect (px, window origin +// top-left) of the W x H canvas centred at the origin in the z = 0 plane. +u_cursor_depth_view +kooima_rect(xrt_vec3 eye, int32_t x, int32_t y, int32_t w, int32_t h) +{ + const float mx = W / (float)WIN_PX_W, my = H / (float)WIN_PX_H; + const float l = -W / 2 + (float)x * mx, r = -W / 2 + (float)(x + w) * mx; + const float t = H / 2 - (float)y * my, b = H / 2 - (float)(y + h) * my; + u_cursor_depth_view v{}; + const float d = eye.z; + v.fov.angle_left = std::atan((l - eye.x) / d); + v.fov.angle_right = std::atan((r - eye.x) / d); + v.fov.angle_up = std::atan((t - eye.y) / d); + v.fov.angle_down = std::atan((b - eye.y) / d); + v.pose.orientation = {0, 0, 0, 1}; + v.pose.position = eye; + return v; +} + +} // namespace + +TEST_CASE("cursor_draw: a zone request maps into the zone's sub-rect of every tile, N views") +{ + // Off-centre zone, four eyes in a row (an N-view zone), viewer off-axis. + const int32_t zx = 300, zy = 200, zw = 600, zh = 500; + const xrt_vec3 eyes[4] = { + {-0.07f, 0.02f, 0.6f}, {-0.025f, 0.02f, 0.6f}, {0.025f, 0.02f, 0.61f}, {0.07f, 0.02f, 0.6f}}; + u_cursor_depth_view zone_v[4], full_v[4]; + for (int i = 0; i < 4; i++) { + zone_v[i] = kooima_rect(eyes[i], zx, zy, zw, zh); + full_v[i] = kooima_rect(eyes[i], 0, 0, (int32_t)WIN_PX_W, (int32_t)WIN_PX_H); + } + + // The hint: cursor at zone UV (0.3, 0.6) - from the zone locate's + // outermost pair, as the state tracker solves it. + u_cursor_depth_geometry g{}; + REQUIRE(u_cursor_depth_geometry_solve(&zone_v[0], &zone_v[3], 0.3f, 0.6f, &g)); + CHECK(g.canvas_height == Catch::Approx(H * (float)zh / (float)WIN_PX_H).margin(1e-5)); // the ZONE's height + + for (float disparity : {0.0f, -0.25f}) { + xrt_vec3 c{}; + float h = 0.0f; + u_cursor_depth_place(&g, disparity, 0.05f, &c, &h); + const float unit = g.canvas_height / (1.0f - disparity); + + u_cursor_draw_request req{}; + req.requested = true; + req.view_count = 4; + for (int i = 0; i < 4; i++) { + REQUIRE(u_cursor_draw_view_from_placement(&zone_v[i], &c, &g.orientation, unit, &req.view[i])); + } + req.zone_valid = true; + req.zone_id = 7; + req.zone_rect = {{zx, zy}, {zw, zh}}; + REQUIRE(u_cursor_draw_should_draw(&req)); + + float clip[4]; + REQUIRE(u_cursor_draw_into_zone(&req, WIN_PX_W, WIN_PX_H, clip)); + CHECK(clip[0] == Catch::Approx(300.0f / 1500.0f)); + CHECK(clip[1] == Catch::Approx(200.0f / 1000.0f)); + CHECK(clip[2] == Catch::Approx(600.0f / 1500.0f)); + CHECK(clip[3] == Catch::Approx(500.0f / 1000.0f)); + + // The oracle: the same 3D point and unit seen through views framed to + // the WHOLE window land exactly where the remapped zone map says, in + // every view - the tile is the window in a zones frame. + for (int i = 0; i < 4; i++) { + u_cursor_draw_view ref{}; + REQUIRE(u_cursor_draw_view_from_placement(&full_v[i], &c, &g.orientation, unit, &ref)); + for (int k = 0; k < 2; k++) { + CHECK(req.view[i].origin[k] == Catch::Approx(ref.origin[k]).margin(1e-4)); + CHECK(req.view[i].axis_x[k] == Catch::Approx(ref.axis_x[k]).margin(1e-4)); + CHECK(req.view[i].axis_y[k] == Catch::Approx(ref.axis_y[k]).margin(1e-4)); + } + } + // On the plane, the hotspot sits at the zone UV inside the zone. + if (disparity == 0.0f) { + for (int i = 0; i < 4; i++) { + CHECK(req.view[i].origin[0] == + Catch::Approx((300.0f + 0.3f * 600.0f) / 1500.0f).margin(1e-4)); + CHECK(req.view[i].origin[1] == + Catch::Approx((200.0f + 0.6f * 500.0f) / 1000.0f).margin(1e-4)); + } + } + // One unit is the zone's height: the canvas the compositor sizes + // the sprite against is the zone's, in the same px. + CHECK(u_cursor_draw_canvas_px_h(&req, WIN_PX_H) == (uint32_t)zh); + } +} + +TEST_CASE("cursor_draw: zone clip and refusals") +{ + u_cursor_draw_request req{}; + req.requested = true; + req.view_count = 2; + req.view[0] = {{0.5f, 0.5f}, {0.1f, 0.0f}, {0.0f, -0.2f}}; + req.view[1] = req.view[0]; + float clip[4]; + + // No zone: untouched, whole tile, the caller's canvas height. + u_cursor_draw_request plain = req; + REQUIRE(u_cursor_draw_into_zone(&plain, 0, 0, clip)); + CHECK(clip[0] == 0.0f); + CHECK(clip[1] == 0.0f); + CHECK(clip[2] == 1.0f); + CHECK(clip[3] == 1.0f); + CHECK(plain.view[0].origin[0] == 0.5f); + CHECK(u_cursor_draw_canvas_px_h(&plain, 900) == 900u); + + // A zone hanging off the window's right/bottom edge clips to the tile; + // the map itself is not clamped (the sprite stays where it belongs). + req.zone_valid = true; + req.zone_rect = {{1000, 800}, {1000, 400}}; + u_cursor_draw_request off = req; + REQUIRE(u_cursor_draw_into_zone(&off, WIN_PX_W, WIN_PX_H, clip)); + CHECK(clip[0] == Catch::Approx(1000.0f / 1500.0f)); + CHECK(clip[2] == Catch::Approx(500.0f / 1500.0f)); + CHECK(clip[1] == Catch::Approx(0.8f)); + CHECK(clip[3] == Catch::Approx(0.2f)); + CHECK(off.view[1].origin[0] == Catch::Approx((1000.0f + 0.5f * 1000.0f) / 1500.0f)); + CHECK(off.view[1].axis_y[1] == Catch::Approx(-0.2f * 400.0f / 1000.0f)); + + // No target size, an empty zone, or a zone wholly outside the window: + // nothing is drawn. + u_cursor_draw_request r2 = req; + CHECK_FALSE(u_cursor_draw_into_zone(&r2, 0, WIN_PX_H, clip)); + r2 = req; + r2.zone_rect = {{10, 10}, {0, 50}}; + CHECK_FALSE(u_cursor_draw_into_zone(&r2, WIN_PX_W, WIN_PX_H, clip)); + r2 = req; + r2.zone_rect = {{1600, 10}, {100, 50}}; + CHECK_FALSE(u_cursor_draw_into_zone(&r2, WIN_PX_W, WIN_PX_H, clip)); +}