From a02c4f70f8bd9b6d719e7148bd73d34eb7b27028 Mon Sep 17 00:00:00 2001 From: byungjul Date: Fri, 2 Oct 2026 14:27:08 +0900 Subject: [PATCH 1/4] chore(#332): put UpdateBakedHitColliders' doc comment back above it The Linux UpdatePointerFromInputSystem() method (#343) was inserted between UpdateBakedHitColliders' doc comment and the method, so the comment read as if it described the new method. Move the Linux method above the comment. No code change. Co-Authored-By: Claude Opus 5.5 --- Runtime/DisplayXRTransparentOverlay.cs | 36 +++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Runtime/DisplayXRTransparentOverlay.cs b/Runtime/DisplayXRTransparentOverlay.cs index 5a01b149..72460192 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 @@ -1201,6 +1183,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; From 18eb71e49f98705f85704d538491424fad8f548f Mon Sep 17 00:00:00 2001 From: byungjul Date: Fri, 2 Oct 2026 14:27:17 +0900 Subject: [PATCH 2/4] fix(#332): round Linux PointerPosition/PointerDelta like macOS The macOS path rounds the cursor to whole window-client pixels (Mathf.RoundToInt on x and on the flipped y) before computing PointerPosition and PointerDelta; the Linux path (#343) did not, so Linux reported fractional pixels. Round the same way. Linux only. Co-Authored-By: Claude Opus 5.5 --- Runtime/DisplayXRTransparentOverlay.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Runtime/DisplayXRTransparentOverlay.cs b/Runtime/DisplayXRTransparentOverlay.cs index 72460192..f831ea05 100644 --- a/Runtime/DisplayXRTransparentOverlay.cs +++ b/Runtime/DisplayXRTransparentOverlay.cs @@ -1172,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; From d65e81d148af9e39ab16b9a17bfd77879492584d Mon Sep 17 00:00:00 2001 From: byungjul Date: Fri, 2 Oct 2026 14:28:16 +0900 Subject: [PATCH 3/4] docs(#336): each overlay canvas has its own stage, not (0,100000,0) Since #342 every Local2D / window-space UI canvas gets its own stage from DisplayXROverlayStage (first at (0,100000,0), then 1000 units apart along x). The WindowSpaceUI sample README and router comment, window-space-ui.md and a leftover comment in DisplayXRWindowSpaceUI still said every canvas sits at (0,100000,0). Also note in DisplayXROverlayStage that the spacing assumes an overlay camera ortho half-width under 1000 world units. Comment/doc only. Co-Authored-By: Claude Opus 5.5 --- Runtime/DisplayXROverlayStage.cs | 3 +++ Runtime/DisplayXRWindowSpaceUI.cs | 6 ++---- Samples~/WindowSpaceUI/DisplayXRWsuiMouseRouter.cs | 6 ++++-- Samples~/WindowSpaceUI/README.md | 6 ++++-- docs~/architecture/window-space-ui.md | 8 ++++++-- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/Runtime/DisplayXROverlayStage.cs b/Runtime/DisplayXROverlayStage.cs index 148e9ddf..efcc81c8 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/DisplayXRWindowSpaceUI.cs b/Runtime/DisplayXRWindowSpaceUI.cs index 646fb36d..3a175cdd 100644 --- a/Runtime/DisplayXRWindowSpaceUI.cs +++ b/Runtime/DisplayXRWindowSpaceUI.cs @@ -107,11 +107,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 diff --git a/Samples~/WindowSpaceUI/DisplayXRWsuiMouseRouter.cs b/Samples~/WindowSpaceUI/DisplayXRWsuiMouseRouter.cs index 5f1179be..62572e57 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 b93d5d4d..c0f7c2fa 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 8a5ec631..e902f2fe 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 From 31e6475a623d763badb56792790fb8f44f6b0c27 Mon Sep 17 00:00:00 2001 From: byungjul Date: Fri, 2 Oct 2026 14:28:41 +0900 Subject: [PATCH 4/4] docs(#336): say how several window-space UIs stack Several DisplayXRWindowSpaceUI components are composited in slot order (lowest slot at the bottom), and each takes the lowest free slot on enable, so the draw order of overlapping HUDs follows enable/disable history, not anything the app sets. Document that (class doc, the slot-acquire comment, window-space-ui.md), advise keeping HUDs from overlapping, and note that under the DisplayXR shell only the first window-space layer is composited. Doc only, no behaviour change. Co-Authored-By: Claude Opus 5.5 --- Runtime/DisplayXRWindowSpaceUI.cs | 11 +++++++++++ docs~/architecture/window-space-ui.md | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/Runtime/DisplayXRWindowSpaceUI.cs b/Runtime/DisplayXRWindowSpaceUI.cs index 3a175cdd..828fb02e 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))] @@ -392,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/docs~/architecture/window-space-ui.md b/docs~/architecture/window-space-ui.md index e902f2fe..6ebe1786 100644 --- a/docs~/architecture/window-space-ui.md +++ b/docs~/architecture/window-space-ui.md @@ -67,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