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
3 changes: 3 additions & 0 deletions Runtime/DisplayXROverlayStage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ internal static class DisplayXROverlayStage
{
static readonly Vector3 kOrigin = new Vector3(0f, 100000f, 0f);
// A 4096-unit canvas at the 0.01 canvas scale is ~41 world units wide.
// Stages sit side by side along x, so this holds while each overlay camera's
// ortho half-width stays under kSpacing (half-width = RT width * 0.005, so an
// RT under ~200000 px wide). Fine in practice.
const float kSpacing = 1000f;

static readonly HashSet<int> s_Used = new HashSet<int>();
Expand Down
41 changes: 21 additions & 20 deletions Runtime/DisplayXRTransparentOverlay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1147,24 +1147,6 @@ private static bool TryRayHitBakedSkinnedMesh(
return any;
}

// For each SkinnedMeshRenderer in clickableRenderers, BakeMesh the
// current animated pose and cache the verts/tris arrays. The actual
// hit test (TryRayHitBakedSkinnedMesh) walks the triangles and
// transforms each through the SMR's localToWorldMatrix at test time,
// so the visible silhouette is what's hit-tested — no MeshCollider
// needed and no transform-pairing to get wrong. Transparent gaps
// inside the AABB (between legs, around hat tip, AABB corners)
// register as misses, which the native overlay routes via
// WM_NCHITTEST = HTTRANSPARENT.
//
// BakeMesh costs ~1–3 ms per frame for a typical character mesh, so
// this only runs for renderers that are actually being drawn (#254) —
// a list holding four characters of which three are deactivated used
// to skin all four. The vertex fetch and the topology check are
// steady-state alloc-free; see BakedHit.
//
// No-op for non-skinned renderers (regular MeshRenderer): they keep
// whatever collider the user attached and use Physics.Raycast.
#if UNITY_STANDALONE_LINUX && !UNITY_EDITOR
// Linux (#332): PointerPosition / PointerDelta / IsLeftPressed / IsRightPressed
// straight from the Input System. On Windows these come from a native poll
Expand All @@ -1190,8 +1172,9 @@ private void UpdatePointerFromInputSystem()
right = Input.GetMouseButton(1);
#endif
// Unity screen space is bottom-left; these properties are window-client
// pixels, top-left origin (what the Windows native poll reports).
pos = new Vector2(pos.x, Screen.height - pos.y);
// pixels, top-left origin (what the Windows native poll reports). Rounded
// to whole pixels like the macOS path, so PointerDelta is whole pixels too.
pos = new Vector2(Mathf.RoundToInt(pos.x), Mathf.RoundToInt(Screen.height - pos.y));
PointerDelta = m_HasPrevPointerPos ? (pos - m_PrevPointerPos) : Vector2.zero;
PointerPosition = pos;
m_PrevPointerPos = pos;
Expand All @@ -1201,6 +1184,24 @@ private void UpdatePointerFromInputSystem()
}
#endif

// For each SkinnedMeshRenderer in clickableRenderers, BakeMesh the
// current animated pose and cache the verts/tris arrays. The actual
// hit test (TryRayHitBakedSkinnedMesh) walks the triangles and
// transforms each through the SMR's localToWorldMatrix at test time,
// so the visible silhouette is what's hit-tested — no MeshCollider
// needed and no transform-pairing to get wrong. Transparent gaps
// inside the AABB (between legs, around hat tip, AABB corners)
// register as misses, which the native overlay routes via
// WM_NCHITTEST = HTTRANSPARENT.
//
// BakeMesh costs ~1–3 ms per frame for a typical character mesh, so
// this only runs for renderers that are actually being drawn (#254) —
// a list holding four characters of which three are deactivated used
// to skin all four. The vertex fetch and the topology check are
// steady-state alloc-free; see BakedHit.
//
// No-op for non-skinned renderers (regular MeshRenderer): they keep
// whatever collider the user attached and use Physics.Raycast.
private void UpdateBakedHitColliders()
{
if (clickableRenderers == null) return;
Expand Down
17 changes: 13 additions & 4 deletions Runtime/DisplayXRWindowSpaceUI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ namespace DisplayXR
/// from its loop). WorldSpace + dedicated camera with targetTexture is the
/// canonical Unity recipe for "render UI to a texture" and works
/// identically across BiRP / URP / HDRP.
///
/// Several of these can be live at once (one native wsui slot each). They are
/// composited in slot order, lowest slot at the bottom, and a component takes the
/// lowest FREE slot on enable — so the draw order of overlapping HUDs is not
/// something the app controls (enable A, enable B, disable A, enable C: C takes
/// slot 0 and draws under B). Keep HUDs from overlapping. Under the DisplayXR
/// shell (workspace tile) the runtime composites only the first window-space
/// layer, so only the lowest-slot HUD shows there.
/// </summary>
[AddComponentMenu("DisplayXR/Window Space UI")]
[RequireComponent(typeof(Canvas))]
Expand Down Expand Up @@ -107,11 +115,9 @@ public class DisplayXRWindowSpaceUI : MonoBehaviour
private int m_PanelSizeSource = kPanelSizeNone;
private int m_ShellMode = -1; // -1 = not yet queried

// We park the WorldSpace canvas at this fixed position, far from any
// scene content, so the dedicated camera looking at it sees nothing
// else that might bleed into our RT.
// This component's private stage (see DisplayXROverlayStage): its canvas and
// camera sit there so no other overlay's camera sees this canvas.
// camera sit there, far from any scene content and from every other overlay's
// stage, so no other camera sees this canvas and nothing bleeds into our RT.
private int m_Stage = -1;

// The URP transparent-overlay foreground clip (DisplayXR/ForegroundClipURP) is a
Expand Down Expand Up @@ -394,6 +400,9 @@ private void IssueVulkanCopy()
// Take a wsui slot for this component. Idempotent. Play mode only: there is no
// session in edit mode (Play Mode is the preview), and an [ExecuteAlways] edit-mode
// or prefab-stage instance holding a slot would starve the play-mode ones.
// The native side hands out the LOWEST free slot, and layers are composited in
// slot order (lowest = bottom), so stacking follows enable/disable history, not
// anything the app sets — see the class doc.
private bool AcquireSlot()
{
if (m_Slot >= 0) return true;
Expand Down
6 changes: 4 additions & 2 deletions Samples~/WindowSpaceUI/DisplayXRWsuiMouseRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
//
// WHY YOU NEED THIS. `XrCompositionLayerWindowSpaceDXR` submits pixels and lets the
// runtime composite them; it does not carry input. DisplayXRWindowSpaceUI takes over
// your Canvas — switches it to WorldSpace, parks it at world (0, 100000, 0) on a
// private layer, and renders it with a hidden offscreen camera into a RenderTexture.
// your Canvas — switches it to WorldSpace, parks it on its own private stage on a
// private layer (each Local2D / window-space UI canvas gets one from
// DisplayXROverlayStage: the first at world (0, 100000, 0), the next ones 1000 units
// apart along x), and renders it with a hidden offscreen camera into a RenderTexture.
// After that the mouse position in the app window and the overlay camera's screen
// space are two different coordinate systems, so GraphicRaycaster never hits anything
// and your buttons and sliders are dead. This bridges the two:
Expand Down
6 changes: 4 additions & 2 deletions Samples~/WindowSpaceUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ Makes the UI rendered by `DisplayXRWindowSpaceUI` **clickable**.
`DisplayXRWindowSpaceUI` submits your Canvas to the runtime as an
`XrCompositionLayerWindowSpaceDXR` composition layer. That layer carries **pixels, not
input**. To render it, the component takes over your Canvas: switches it to `WorldSpace`,
parks it at world `(0, 100000, 0)` on a private layer, and renders it with a hidden
offscreen camera into a RenderTexture.
parks it on its own private "stage" far from your scene (each Local2D / window-space UI
canvas gets one from `DisplayXROverlayStage`: the first at world `(0, 100000, 0)`, the next
ones 1000 units apart along x) on a private layer, and renders it with a hidden offscreen
camera into a RenderTexture.

After that, the mouse position in your app window and the overlay camera's screen space are
two different coordinate systems. `GraphicRaycaster` never hits anything, so **buttons and
Expand Down
18 changes: 16 additions & 2 deletions docs~/architecture/window-space-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,16 @@ For a whole scene that should be flat, see [`two-dimensional-scenes.md`](two-dim
The component does not render your Canvas where you put it. It:

1. Switches the Canvas to `RenderMode.WorldSpace`.
2. Parks it at world **`(0, 100000, 0)`** on **layer 30**, a private layer, well away from
your scene. The overlay camera culls to that layer alone, so the layer is **re-applied
2. Parks it on its own private **stage**, well away from your scene, on **layer 30**, a
private layer. The overlay camera culls to that layer alone, so the layer is **re-applied
to every descendant each `LateUpdate`** — anything you `Instantiate` under the canvas at
runtime (list items, tiles, a file dialog, a dropdown's blocker) is born on its prefab's
layer and would otherwise be silently culled. You never need to set layers yourself;
the first time the component has to fix a stray object it logs once.
Every Local2D / window-space UI canvas gets its own stage from `DisplayXROverlayStage`:
the first at world **`(0, 100000, 0)`**, the next ones 1000 units apart along x. They all
share layer 30, so the separate stages are what stop each overlay camera from also
rendering the other canvases (#342).
3. Sizes it so **1 RT pixel == 1 UI unit** (`sizeDelta = OverlayResolution`, `localScale = 0.01`).
4. Renders it with a dedicated hidden orthographic camera into `OverlayTexture`.
5. Hands that texture to the runtime as an `XrCompositionLayerWindowSpaceDXR` composition
Expand All @@ -63,6 +67,16 @@ HDRP alike. The overlay camera is left **enabled** deliberately (rather than dri
manual `Camera.Render()`), because under URP's RenderGraph a manual render targets the
backbuffer and blanks the whole XR mirror — a pure-black docked Game view.

**Several HUDs at once (#336).** Up to four `DisplayXRWindowSpaceUI` components can be live
at the same time. Each one takes a native **slot** on enable — always the **lowest free**
one — and gives it back on disable; a fifth waits (it retries about once a second) and
appears when one is freed. The layers are composited in **slot order: lowest slot at the
bottom**, higher slots on top. The app does not control that order: enable A, enable B,
disable A, enable C — C takes slot 0 and now draws **under** B. So the stacking of
**overlapping** HUDs is not guaranteed; keep your HUDs from overlapping. Under the DisplayXR
**shell** (workspace tile) the runtime composites only the first window-space layer, so only
the lowest-slot HUD shows there.

## Input

**The composition layer carries pixels, not input.** Nothing about submitting a layer tells
Expand Down
Loading