diff --git a/Runtime/DisplayXROverlayStage.cs b/Runtime/DisplayXROverlayStage.cs index 148e9dd..efcc81c 100644 --- a/Runtime/DisplayXROverlayStage.cs +++ b/Runtime/DisplayXROverlayStage.cs @@ -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 s_Used = new HashSet(); diff --git a/Runtime/DisplayXRTransparentOverlay.cs b/Runtime/DisplayXRTransparentOverlay.cs index 5a01b14..f831ea0 100644 --- a/Runtime/DisplayXRTransparentOverlay.cs +++ b/Runtime/DisplayXRTransparentOverlay.cs @@ -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 @@ -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; @@ -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; diff --git a/Runtime/DisplayXRWindowSpaceUI.cs b/Runtime/DisplayXRWindowSpaceUI.cs index 646fb36..828fb02 100644 --- a/Runtime/DisplayXRWindowSpaceUI.cs +++ b/Runtime/DisplayXRWindowSpaceUI.cs @@ -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. /// [AddComponentMenu("DisplayXR/Window Space UI")] [RequireComponent(typeof(Canvas))] @@ -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 @@ -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; diff --git a/Samples~/WindowSpaceUI/DisplayXRWsuiMouseRouter.cs b/Samples~/WindowSpaceUI/DisplayXRWsuiMouseRouter.cs index 5f1179b..62572e5 100644 --- a/Samples~/WindowSpaceUI/DisplayXRWsuiMouseRouter.cs +++ b/Samples~/WindowSpaceUI/DisplayXRWsuiMouseRouter.cs @@ -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: diff --git a/Samples~/WindowSpaceUI/README.md b/Samples~/WindowSpaceUI/README.md index b93d5d4..c0f7c2f 100644 --- a/Samples~/WindowSpaceUI/README.md +++ b/Samples~/WindowSpaceUI/README.md @@ -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 diff --git a/docs~/architecture/window-space-ui.md b/docs~/architecture/window-space-ui.md index 8a5ec63..6ebe178 100644 --- a/docs~/architecture/window-space-ui.md +++ b/docs~/architecture/window-space-ui.md @@ -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 @@ -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