From 32ccfa332eb32c8348b0b76cd2e2fa7d55fd94fb Mon Sep 17 00:00:00 2001 From: byungjul Date: Thu, 1 Oct 2026 17:36:00 +0900 Subject: [PATCH 1/2] fix: give each Local2D / window-space UI canvas its own stage DisplayXRLocal2D and DisplayXRWindowSpaceUI park their offscreen canvas at one shared world position on the shared private layer 30, and each overlay camera culls to that layer. With two or more overlays every camera also rendered the other canvases: in lenovo-avatar the speech bubble's Local2D texture held the radio and tuning HUDs on top of the bubble text (verified by reading the RT back). The layer can't separate them because they share it. DisplayXROverlayStage hands each live overlay its own stage, 1000 world units apart (a 4096-unit canvas at scale 0.01 is ~41 units; the cameras' far plane is 10), acquired in OnEnable and released in OnDisable. Behaviour change on every platform: an overlay's texture now contains only its own canvas. Scenes with a single overlay are unaffected. Co-Authored-By: Claude Opus 5.5 --- Runtime/DisplayXRLocal2D.cs | 12 +++++-- Runtime/DisplayXROverlayStage.cs | 47 +++++++++++++++++++++++++++ Runtime/DisplayXROverlayStage.cs.meta | 11 +++++++ Runtime/DisplayXRWindowSpaceUI.cs | 12 +++++-- 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 Runtime/DisplayXROverlayStage.cs create mode 100644 Runtime/DisplayXROverlayStage.cs.meta diff --git a/Runtime/DisplayXRLocal2D.cs b/Runtime/DisplayXRLocal2D.cs index 6f9f078..a9e841c 100644 --- a/Runtime/DisplayXRLocal2D.cs +++ b/Runtime/DisplayXRLocal2D.cs @@ -198,7 +198,9 @@ private Vector2Int RtSize /// The RenderTexture capturing the Canvas content. public RenderTexture OverlayTexture { get; private set; } - private static readonly Vector3 kCanvasWorldPos = new Vector3(0, 100000f, 0); + // This component's private stage (see DisplayXROverlayStage): its canvas and + // camera sit there so no other overlay's camera sees this canvas. + private int m_Stage = -1; private const int kPrivateLayer = 30; private Canvas m_Canvas; @@ -310,7 +312,9 @@ void OnEnable() // WorldSpace canvas parked far away on a private layer. m_Canvas.renderMode = RenderMode.WorldSpace; - m_CanvasRect.position = kCanvasWorldPos; + if (m_Stage < 0) m_Stage = DisplayXROverlayStage.Acquire(); + Vector3 stagePos = DisplayXROverlayStage.Position(m_Stage); + m_CanvasRect.position = stagePos; m_CanvasRect.rotation = Quaternion.identity; m_CanvasRect.localScale = new Vector3(0.01f, 0.01f, 0.01f); m_CanvasRect.sizeDelta = resolution; @@ -321,7 +325,7 @@ void OnEnable() var camGO = new GameObject("DisplayXR_Local2DCam"); camGO.transform.SetParent(transform, false); camGO.hideFlags = HideFlags.HideAndDontSave; - camGO.transform.position = kCanvasWorldPos + new Vector3(0, 0, 1); + camGO.transform.position = stagePos + new Vector3(0, 0, 1); camGO.transform.rotation = Quaternion.LookRotation(Vector3.back, Vector3.down); m_OverlayCamera = camGO.AddComponent(); @@ -459,6 +463,8 @@ private void ReleaseBridgeTex() void OnDisable() { + DisplayXROverlayStage.Release(m_Stage); + m_Stage = -1; UnregisterContentDirtyTracking(); // #244 — drop Graphic dirty callbacks ReleaseBridgeTex(); diff --git a/Runtime/DisplayXROverlayStage.cs b/Runtime/DisplayXROverlayStage.cs new file mode 100644 index 0000000..148e9dd --- /dev/null +++ b/Runtime/DisplayXROverlayStage.cs @@ -0,0 +1,47 @@ +// Copyright 2024-2026, DisplayXR contributors +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using UnityEngine; + +namespace DisplayXR +{ + /// + /// Private world-space "stage" positions for the offscreen canvases of + /// and . + /// + /// Each component parks its canvas far from the scene on a private layer and + /// renders it with its own ortho camera that culls to that layer. They all used + /// one shared spot, so with two or more overlays every camera also rendered the + /// other canvases: a HUD showed up inside the Local2D bubble and vice versa (the + /// layer can't separate them — they share it). Giving each live overlay its own + /// stage, spaced far beyond any canvas's extent and the cameras' 10-unit far + /// plane, keeps each camera on its own canvas. + /// + 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. + const float kSpacing = 1000f; + + static readonly HashSet s_Used = new HashSet(); + + /// Take the lowest free stage index. + public static int Acquire() + { + int i = 0; + while (s_Used.Contains(i)) i++; + s_Used.Add(i); + return i; + } + + /// Give a stage back. Negative indices are ignored. + public static void Release(int index) + { + if (index >= 0) s_Used.Remove(index); + } + + /// World position of the canvas on stage . + public static Vector3 Position(int index) => kOrigin + new Vector3(index * kSpacing, 0f, 0f); + } +} diff --git a/Runtime/DisplayXROverlayStage.cs.meta b/Runtime/DisplayXROverlayStage.cs.meta new file mode 100644 index 0000000..97e4cff --- /dev/null +++ b/Runtime/DisplayXROverlayStage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3061c7b5d24e44a2b50675d12268e867 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/DisplayXRWindowSpaceUI.cs b/Runtime/DisplayXRWindowSpaceUI.cs index cffa097..4bd5185 100644 --- a/Runtime/DisplayXRWindowSpaceUI.cs +++ b/Runtime/DisplayXRWindowSpaceUI.cs @@ -110,7 +110,9 @@ public class DisplayXRWindowSpaceUI : MonoBehaviour // 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. - private static readonly Vector3 kCanvasWorldPos = new Vector3(0, 100000f, 0); + // This component's private stage (see DisplayXROverlayStage): its canvas and + // camera sit there so no other overlay's camera sees this canvas. + private int m_Stage = -1; // Dedicated layer: we put the canvas + children on this layer and give // ONLY our overlay camera that layer in its cullingMask. We pick a // mid-range layer that's typically unused (Unity reserves 0-7 for @@ -185,7 +187,9 @@ void OnEnable() m_Canvas.renderMode = RenderMode.WorldSpace; // worldCamera is assigned to the OverlayCamera below (after creation) // so GraphicRaycaster can project screen-cursor input onto the canvas. - m_CanvasRect.position = kCanvasWorldPos; + if (m_Stage < 0) m_Stage = DisplayXROverlayStage.Acquire(); + Vector3 stagePos = DisplayXROverlayStage.Position(m_Stage); + m_CanvasRect.position = stagePos; m_CanvasRect.rotation = Quaternion.identity; // Use the canvas's existing reference width as the scale baseline. // 1 world unit per UI unit at scale 1 → set scale so the RT @@ -208,7 +212,7 @@ void OnEnable() // pipeline and the swapchain image our native blit feeds the // runtime compositor with. Without this the panel reads // upside-down in the runtime preview window. - camGO.transform.position = kCanvasWorldPos + new Vector3(0, 0, 1); + camGO.transform.position = stagePos + new Vector3(0, 0, 1); camGO.transform.rotation = Quaternion.LookRotation(Vector3.back, Vector3.down); m_OverlayCamera = camGO.AddComponent(); @@ -453,6 +457,8 @@ private void ReleaseBridgeTex() void OnDisable() { ReleaseSlot(); + DisplayXROverlayStage.Release(m_Stage); + m_Stage = -1; // Restore the canvas's original mode + transform + layer. if (m_StateSaved && m_Canvas != null) From 9f980d5c92e994013ce9a75699146134de3c9c30 Mon Sep 17 00:00:00 2001 From: byungjul Date: Thu, 1 Oct 2026 17:45:13 +0900 Subject: [PATCH 2/2] fix: scope the URP foreground clip out of the window-space UI camera The transparent-overlay foreground clip (DisplayXR/ForegroundClipURP) is a full-screen pass on the URP renderer, so it also ran for DisplayXRWindowSpaceUI's offscreen overlay camera and discarded the whole canvas: the HUD RT came out fully transparent and nothing ever showed (verified by reading the RT back: all zero; with this change both lenovo-avatar HUDs render). DisplayXRLocal2D already zeroes _DXRForegroundFar around its own camera's render; do the same here. Behaviour change on every platform for apps that wire the URP foreground clip: their window-space UI now renders. Apps without it are unaffected. Co-Authored-By: Claude Opus 5.5 --- Runtime/DisplayXRWindowSpaceUI.cs | 40 ++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/Runtime/DisplayXRWindowSpaceUI.cs b/Runtime/DisplayXRWindowSpaceUI.cs index 4bd5185..646fb36 100644 --- a/Runtime/DisplayXRWindowSpaceUI.cs +++ b/Runtime/DisplayXRWindowSpaceUI.cs @@ -3,7 +3,7 @@ using UnityEngine; using UnityEngine.Experimental.Rendering; -using UnityEngine.Rendering; +using UnityEngine.Rendering; // RenderPipelineManager, ScriptableRenderContext namespace DisplayXR { @@ -113,6 +113,15 @@ public class DisplayXRWindowSpaceUI : MonoBehaviour // This component's private stage (see DisplayXROverlayStage): its canvas and // camera sit there so no other overlay's camera sees this canvas. private int m_Stage = -1; + + // The URP transparent-overlay foreground clip (DisplayXR/ForegroundClipURP) is a + // full-screen pass on the renderer, so it also runs for this overlay camera and, + // keyed on _DXRForegroundFar (z>0.5 = armed), discards the whole canvas: the RT + // comes out fully transparent and the HUD never shows. Same fix as + // DisplayXRLocal2D: zero the global for our camera's render, restore after. + private static readonly int s_ForegroundFarId = Shader.PropertyToID("_DXRForegroundFar"); + private Vector4 m_SavedForegroundFar; + private bool m_CamRenderHooked; // Dedicated layer: we put the canvas + children on this layer and give // ONLY our overlay camera that layer in its cullingMask. We pick a // mid-range layer that's typically unused (Unity reserves 0-7 for @@ -240,6 +249,12 @@ void OnEnable() // Mirrors DisplayXRLocal2D (which was migrated off manual Render for the // canvas-rebuild race — same enabled=true resolution). m_OverlayCamera.enabled = true; + if (!m_CamRenderHooked) + { + RenderPipelineManager.beginCameraRendering += OnBeginOverlayCamera; + RenderPipelineManager.endCameraRendering += OnEndOverlayCamera; + m_CamRenderHooked = true; + } // Wire OverlayCamera as the canvas's event camera. GraphicRaycaster // needs a camera reference to project screen-cursor input onto a @@ -454,9 +469,32 @@ private void ReleaseBridgeTex() m_BridgePtr = System.IntPtr.Zero; } + // Scope the URP foreground clip out of our overlay camera (see the field doc). + // Camera-identity gated, so other cameras are untouched. + void OnBeginOverlayCamera(ScriptableRenderContext ctx, Camera cam) + { + if (cam != m_OverlayCamera) return; + m_SavedForegroundFar = Shader.GetGlobalVector(s_ForegroundFarId); + if (m_SavedForegroundFar.z > 0.5f) + Shader.SetGlobalVector(s_ForegroundFarId, Vector4.zero); + } + + void OnEndOverlayCamera(ScriptableRenderContext ctx, Camera cam) + { + if (cam != m_OverlayCamera) return; + if (m_SavedForegroundFar.z > 0.5f) + Shader.SetGlobalVector(s_ForegroundFarId, m_SavedForegroundFar); + } + void OnDisable() { ReleaseSlot(); + if (m_CamRenderHooked) + { + RenderPipelineManager.beginCameraRendering -= OnBeginOverlayCamera; + RenderPipelineManager.endCameraRendering -= OnEndOverlayCamera; + m_CamRenderHooked = false; + } DisplayXROverlayStage.Release(m_Stage); m_Stage = -1;