From 92eae8c1f191e67cab57fea293168b406b12d4c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 05:49:47 +0000 Subject: [PATCH 1/4] Initial plan From 465ff6d3f5c4732664e02fd5170f6f94249facc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 05:58:04 +0000 Subject: [PATCH 2/4] Fix: keep subtitle canvas in ScreenSpaceOverlay to avoid BopVisualEffects The canvas was being switched to ScreenSpaceCamera mode using the game's main camera, which has BopVisualEffects post-processing. This caused subtitles to be distorted by those effects. Changes: - TmpSubtitleOverlay: assign canvas to Unity's built-in UI layer (5), remove SetCamera() which was the path to ScreenSpaceCamera mode - SubtitleRuntimeController: remove RefreshSubtitleCamera() and its camera-tracking state (_cachedSubtitleCamera, _nextCameraResolveTime, CameraResolveIntervalSeconds); keep public ResolveSubtitleCamera() The canvas now permanently stays in ScreenSpaceOverlay mode, rendering directly to the screen without passing through any game camera. Co-authored-by: Brollyy <12004018+Brollyy@users.noreply.github.com> --- .../Core/SubtitleRuntimeController.cs | 37 ------------------- BopSubtitleReader/Core/TmpSubtitleOverlay.cs | 36 ++---------------- 2 files changed, 3 insertions(+), 70 deletions(-) diff --git a/BopSubtitleReader/Core/SubtitleRuntimeController.cs b/BopSubtitleReader/Core/SubtitleRuntimeController.cs index fbf06da..12ac247 100644 --- a/BopSubtitleReader/Core/SubtitleRuntimeController.cs +++ b/BopSubtitleReader/Core/SubtitleRuntimeController.cs @@ -38,9 +38,6 @@ public sealed class SubtitleRuntimeController : MonoBehaviour private SubtitleCue? _cachedKaraokeCue; private int _cachedActiveSegmentIndex = -1; private float _cachedKaraokeBeat = float.NaN; - private Camera? _cachedSubtitleCamera; - private float _nextCameraResolveTime; - private const float CameraResolveIntervalSeconds = 0.5f; public bool HasActiveSession => _track is not null && _loader is not null && _config is not null; public static SubtitleRuntimeController Instance @@ -70,9 +67,6 @@ public void StartSession(MixtapeLoaderCustom loader, SubtitleTrack track, Subtit _cachedKaraokeCue = null; _cachedActiveSegmentIndex = -1; _cachedKaraokeBeat = float.NaN; - _cachedSubtitleCamera = null; - _nextCameraResolveTime = 0f; - RefreshSubtitleCamera(force: true); _overlay.Hide(); var karaokeCueCount = track.Cues.Count(c => c.KaraokeSegments.Count > 0); Log.Info($"Subtitle session started with {track.Cues.Count} cue(s), {karaokeCueCount} karaoke cue(s)."); @@ -89,8 +83,6 @@ public void StopSession() _cachedKaraokeCue = null; _cachedActiveSegmentIndex = -1; _cachedKaraokeBeat = float.NaN; - _cachedSubtitleCamera = null; - _nextCameraResolveTime = 0f; _overlay.Hide(); } @@ -116,8 +108,6 @@ private void Update() return; } - RefreshSubtitleCamera(); - if (!_timingResolved) { ResolveCueTiming(_track, jukebox); @@ -272,33 +262,6 @@ private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox) track.Cues.Sort((a, b) => a.StartBeat.CompareTo(b.StartBeat)); } - private void RefreshSubtitleCamera(bool force = false) - { - if (_loader is null || !_loader) - { - return; - } - - var shouldResolve = force - || Time.unscaledTime >= _nextCameraResolveTime - || _cachedSubtitleCamera is null - || !_cachedSubtitleCamera - || !_cachedSubtitleCamera.enabled; - if (!shouldResolve) - { - return; - } - - var resolvedCamera = ResolveSubtitleCamera(_loader); - if (force || !ReferenceEquals(_cachedSubtitleCamera, resolvedCamera)) - { - _cachedSubtitleCamera = resolvedCamera; - _overlay.SetCamera(resolvedCamera); - } - - _nextCameraResolveTime = Time.unscaledTime + CameraResolveIntervalSeconds; - } - private static SubtitleCue? FindActiveCue(List cues, float beat) { var low = 0; diff --git a/BopSubtitleReader/Core/TmpSubtitleOverlay.cs b/BopSubtitleReader/Core/TmpSubtitleOverlay.cs index d95e0f0..eb0160d 100644 --- a/BopSubtitleReader/Core/TmpSubtitleOverlay.cs +++ b/BopSubtitleReader/Core/TmpSubtitleOverlay.cs @@ -121,39 +121,6 @@ public void Hide() } } - public void SetCamera(Camera? camera) - { - if (!EnsureInitialized() || _canvas is null) - { - return; - } - - if (camera is not null) - { - if (_canvas.renderMode != RenderMode.ScreenSpaceCamera) - { - _canvas.renderMode = RenderMode.ScreenSpaceCamera; - } - - if (!ReferenceEquals(_canvas.worldCamera, camera)) - { - _canvas.worldCamera = camera; - } - - return; - } - - if (_canvas.renderMode != RenderMode.ScreenSpaceOverlay) - { - _canvas.renderMode = RenderMode.ScreenSpaceOverlay; - } - - if (_canvas.worldCamera is not null) - { - _canvas.worldCamera = null; - } - } - private bool EnsureInitialized() { if (_available) @@ -170,6 +137,9 @@ private bool EnsureInitialized() var canvasObject = new GameObject("BOP_SubtitleCanvas"); Object.DontDestroyOnLoad(canvasObject); + // Place on the UI layer so that game-world cameras (and their post-processing + // effects such as BopVisualEffects) cannot affect the subtitle canvas. + canvasObject.layer = 5; // Unity built-in "UI" layer var canvas = canvasObject.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; From adbb45240dea2ea017705239585598e8367af5cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:11:35 +0000 Subject: [PATCH 3/4] Fix editor viewport: use dedicated overlay camera instead of ScreenSpaceOverlay The ScreenSpaceOverlay fix didn't work in the Bits & Bops editor because the game camera is not full-screen - subtitles would appear over the entire screen rather than within the game viewport. New approach: create a dedicated BOP_SubtitleCameraHost camera that: - Has clearFlags=Depth (overlays without clearing background) - Has cullingMask=0 (renders no 3D scene objects, only the canvas) - Has depth=100 (renders after the main game camera) - Has no post-processing components (immune to BopVisualEffects) Canvas uses ScreenSpaceCamera mode with this overlay camera. RefreshSubtitleCamera() is restored to sync the overlay camera's viewport rect to the game camera's rect via SetReferenceCamera(), keeping subtitles within the game viewport in the editor. Co-authored-by: Brollyy <12004018+Brollyy@users.noreply.github.com> --- .../Core/SubtitleRuntimeController.cs | 37 ++++++++++++++++ BopSubtitleReader/Core/TmpSubtitleOverlay.cs | 42 +++++++++++++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/BopSubtitleReader/Core/SubtitleRuntimeController.cs b/BopSubtitleReader/Core/SubtitleRuntimeController.cs index 12ac247..7b0fbd7 100644 --- a/BopSubtitleReader/Core/SubtitleRuntimeController.cs +++ b/BopSubtitleReader/Core/SubtitleRuntimeController.cs @@ -38,6 +38,9 @@ public sealed class SubtitleRuntimeController : MonoBehaviour private SubtitleCue? _cachedKaraokeCue; private int _cachedActiveSegmentIndex = -1; private float _cachedKaraokeBeat = float.NaN; + private Camera? _cachedSubtitleCamera; + private float _nextCameraResolveTime; + private const float CameraResolveIntervalSeconds = 0.5f; public bool HasActiveSession => _track is not null && _loader is not null && _config is not null; public static SubtitleRuntimeController Instance @@ -67,6 +70,9 @@ public void StartSession(MixtapeLoaderCustom loader, SubtitleTrack track, Subtit _cachedKaraokeCue = null; _cachedActiveSegmentIndex = -1; _cachedKaraokeBeat = float.NaN; + _cachedSubtitleCamera = null; + _nextCameraResolveTime = 0f; + RefreshSubtitleCamera(force: true); _overlay.Hide(); var karaokeCueCount = track.Cues.Count(c => c.KaraokeSegments.Count > 0); Log.Info($"Subtitle session started with {track.Cues.Count} cue(s), {karaokeCueCount} karaoke cue(s)."); @@ -83,6 +89,8 @@ public void StopSession() _cachedKaraokeCue = null; _cachedActiveSegmentIndex = -1; _cachedKaraokeBeat = float.NaN; + _cachedSubtitleCamera = null; + _nextCameraResolveTime = 0f; _overlay.Hide(); } @@ -108,6 +116,8 @@ private void Update() return; } + RefreshSubtitleCamera(); + if (!_timingResolved) { ResolveCueTiming(_track, jukebox); @@ -262,6 +272,33 @@ private static void ResolveCueTiming(SubtitleTrack track, JukeboxScript jukebox) track.Cues.Sort((a, b) => a.StartBeat.CompareTo(b.StartBeat)); } + private void RefreshSubtitleCamera(bool force = false) + { + if (_loader is null || !_loader) + { + return; + } + + var shouldResolve = force + || Time.unscaledTime >= _nextCameraResolveTime + || _cachedSubtitleCamera is null + || !_cachedSubtitleCamera + || !_cachedSubtitleCamera.enabled; + if (!shouldResolve) + { + return; + } + + var resolvedCamera = ResolveSubtitleCamera(_loader); + if (force || !ReferenceEquals(_cachedSubtitleCamera, resolvedCamera)) + { + _cachedSubtitleCamera = resolvedCamera; + _overlay.SetReferenceCamera(resolvedCamera); + } + + _nextCameraResolveTime = Time.unscaledTime + CameraResolveIntervalSeconds; + } + private static SubtitleCue? FindActiveCue(List cues, float beat) { var low = 0; diff --git a/BopSubtitleReader/Core/TmpSubtitleOverlay.cs b/BopSubtitleReader/Core/TmpSubtitleOverlay.cs index eb0160d..4e25a11 100644 --- a/BopSubtitleReader/Core/TmpSubtitleOverlay.cs +++ b/BopSubtitleReader/Core/TmpSubtitleOverlay.cs @@ -15,6 +15,8 @@ public sealed class TmpSubtitleOverlay private bool _initializationAttempted; private bool _available; private GameObject? _host; + private GameObject? _cameraHost; + private Camera? _overlayCamera; private Canvas? _canvas; private TextMeshProUGUI? _tmpText; private RectTransform? _tmpRect; @@ -121,6 +123,24 @@ public void Hide() } } + /// + /// Syncs the subtitle overlay camera's viewport to match 's + /// viewport rect so that subtitles are confined to the same on-screen area (important when + /// the game camera is not full-screen, e.g. in the Bits & Bops editor). + /// Pass null to reset to a full-screen viewport. + /// + public void SetReferenceCamera(Camera? referenceCamera) + { + if (_overlayCamera is null) + { + return; + } + + _overlayCamera.rect = referenceCamera + ? referenceCamera.rect + : new Rect(0f, 0f, 1f, 1f); + } + private bool EnsureInitialized() { if (_available) @@ -135,14 +155,30 @@ private bool EnsureInitialized() _initializationAttempted = true; + // Create a dedicated overlay camera for the subtitle canvas. + // Using a separate camera (not the game camera) prevents any post-processing effects + // such as BopVisualEffects from being applied to the subtitle canvas. + // Using ScreenSpaceCamera (rather than ScreenSpaceOverlay) ensures the canvas viewport + // tracks the game camera's viewport rect, which is important in the editor where the + // game view is not full-screen. + var cameraHost = new GameObject("BOP_SubtitleCameraHost"); + Object.DontDestroyOnLoad(cameraHost); + var overlayCamera = cameraHost.AddComponent(); + overlayCamera.clearFlags = CameraClearFlags.Depth; + overlayCamera.cullingMask = 0; // Render nothing from the 3D scene; only the canvas + overlayCamera.depth = 100; // Render after the main game camera + overlayCamera.rect = new Rect(0f, 0f, 1f, 1f); + _cameraHost = cameraHost; + _overlayCamera = overlayCamera; + var canvasObject = new GameObject("BOP_SubtitleCanvas"); Object.DontDestroyOnLoad(canvasObject); - // Place on the UI layer so that game-world cameras (and their post-processing - // effects such as BopVisualEffects) cannot affect the subtitle canvas. + // Place on the UI layer so that game-world cameras cannot accidentally render it. canvasObject.layer = 5; // Unity built-in "UI" layer var canvas = canvasObject.AddComponent(); - canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvas.renderMode = RenderMode.ScreenSpaceCamera; + canvas.worldCamera = overlayCamera; canvas.sortingOrder = 100; _canvas = canvas; From 852db2317b99adac80ae5c386ae865288a057a76 Mon Sep 17 00:00:00 2001 From: Brollyy Date: Mon, 23 Mar 2026 09:01:41 +0100 Subject: [PATCH 4/4] Fixed issues with the subs not showing up and not tracking camera correctly after the changes. --- .../Core/SubtitleRuntimeController.cs | 49 ++++++++++++++++++- BopSubtitleReader/Core/TmpSubtitleOverlay.cs | 34 +++++++++---- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/BopSubtitleReader/Core/SubtitleRuntimeController.cs b/BopSubtitleReader/Core/SubtitleRuntimeController.cs index 7b0fbd7..3e25d1c 100644 --- a/BopSubtitleReader/Core/SubtitleRuntimeController.cs +++ b/BopSubtitleReader/Core/SubtitleRuntimeController.cs @@ -39,6 +39,7 @@ public sealed class SubtitleRuntimeController : MonoBehaviour private int _cachedActiveSegmentIndex = -1; private float _cachedKaraokeBeat = float.NaN; private Camera? _cachedSubtitleCamera; + private CameraViewportState? _cachedSubtitleViewportState; private float _nextCameraResolveTime; private const float CameraResolveIntervalSeconds = 0.5f; public bool HasActiveSession => _track is not null && _loader is not null && _config is not null; @@ -71,6 +72,7 @@ public void StartSession(MixtapeLoaderCustom loader, SubtitleTrack track, Subtit _cachedActiveSegmentIndex = -1; _cachedKaraokeBeat = float.NaN; _cachedSubtitleCamera = null; + _cachedSubtitleViewportState = null; _nextCameraResolveTime = 0f; RefreshSubtitleCamera(force: true); _overlay.Hide(); @@ -90,6 +92,7 @@ public void StopSession() _cachedActiveSegmentIndex = -1; _cachedKaraokeBeat = float.NaN; _cachedSubtitleCamera = null; + _cachedSubtitleViewportState = null; _nextCameraResolveTime = 0f; _overlay.Hide(); } @@ -290,9 +293,14 @@ private void RefreshSubtitleCamera(bool force = false) } var resolvedCamera = ResolveSubtitleCamera(_loader); - if (force || !ReferenceEquals(_cachedSubtitleCamera, resolvedCamera)) + var resolvedViewportState = CameraViewportState.Capture(resolvedCamera); + var cameraChanged = !ReferenceEquals(_cachedSubtitleCamera, resolvedCamera); + var viewportChanged = !_cachedSubtitleViewportState.HasValue + || !CameraViewportState.AreEquivalent(_cachedSubtitleViewportState.Value, resolvedViewportState); + if (force || cameraChanged || viewportChanged) { _cachedSubtitleCamera = resolvedCamera; + _cachedSubtitleViewportState = resolvedViewportState; _overlay.SetReferenceCamera(resolvedCamera); } @@ -630,4 +638,43 @@ private static string Truncate(string value, int max) return value.Substring(0, max) + "..."; } + private readonly struct CameraViewportState + { + public CameraViewportState(Rect rect, Rect pixelRect, RenderTexture? targetTexture) + { + Rect = rect; + PixelRect = pixelRect; + TargetTexture = targetTexture; + } + + public Rect Rect { get; } + public Rect PixelRect { get; } + public RenderTexture? TargetTexture { get; } + + public static CameraViewportState Capture(Camera? camera) + { + return camera is null + ? new CameraViewportState( + new Rect(0f, 0f, 1f, 1f), + new Rect(0f, 0f, Screen.width, Screen.height), + null) + : new CameraViewportState(camera.rect, camera.pixelRect, camera.targetTexture); + } + + public static bool AreEquivalent(CameraViewportState left, CameraViewportState right) + { + return AreRectsApproximatelyEqual(left.Rect, right.Rect) + && AreRectsApproximatelyEqual(left.PixelRect, right.PixelRect) + && ReferenceEquals(left.TargetTexture, right.TargetTexture); + } + + private static bool AreRectsApproximatelyEqual(Rect a, Rect b) + { + return Mathf.Approximately(a.x, b.x) + && Mathf.Approximately(a.y, b.y) + && Mathf.Approximately(a.width, b.width) + && Mathf.Approximately(a.height, b.height); + } + } + } diff --git a/BopSubtitleReader/Core/TmpSubtitleOverlay.cs b/BopSubtitleReader/Core/TmpSubtitleOverlay.cs index 4e25a11..65fa147 100644 --- a/BopSubtitleReader/Core/TmpSubtitleOverlay.cs +++ b/BopSubtitleReader/Core/TmpSubtitleOverlay.cs @@ -11,13 +11,12 @@ namespace BopSubtitleReader.Core; public sealed class TmpSubtitleOverlay { private static readonly ClassLogger Log = ClassLogger.GetForClass(); + private const int UiLayer = 5; // Unity built-in "UI" layer private bool _initializationAttempted; private bool _available; private GameObject? _host; - private GameObject? _cameraHost; private Camera? _overlayCamera; - private Canvas? _canvas; private TextMeshProUGUI? _tmpText; private RectTransform? _tmpRect; private GameObject? _karaokeLayer; @@ -131,14 +130,25 @@ public void Hide() /// public void SetReferenceCamera(Camera? referenceCamera) { - if (_overlayCamera is null) + if (!EnsureInitialized() || _overlayCamera is null) { return; } - _overlayCamera.rect = referenceCamera - ? referenceCamera.rect - : new Rect(0f, 0f, 1f, 1f); + if (referenceCamera is null) + { + _overlayCamera.targetDisplay = 0; + _overlayCamera.targetTexture = null; + _overlayCamera.rect = new Rect(0f, 0f, 1f, 1f); + _overlayCamera.pixelRect = new Rect(0f, 0f, Screen.width, Screen.height); + return; + } + + // Mirror display + render target so subtitles render in the same output surface as gameplay. + _overlayCamera.targetDisplay = referenceCamera.targetDisplay; + _overlayCamera.targetTexture = referenceCamera.targetTexture; + _overlayCamera.rect = referenceCamera.rect; + _overlayCamera.pixelRect = referenceCamera.pixelRect; } private bool EnsureInitialized() @@ -165,24 +175,23 @@ private bool EnsureInitialized() Object.DontDestroyOnLoad(cameraHost); var overlayCamera = cameraHost.AddComponent(); overlayCamera.clearFlags = CameraClearFlags.Depth; - overlayCamera.cullingMask = 0; // Render nothing from the 3D scene; only the canvas + overlayCamera.cullingMask = 1 << UiLayer; // Render only the UI layer for the subtitle canvas overlayCamera.depth = 100; // Render after the main game camera overlayCamera.rect = new Rect(0f, 0f, 1f, 1f); - _cameraHost = cameraHost; _overlayCamera = overlayCamera; var canvasObject = new GameObject("BOP_SubtitleCanvas"); Object.DontDestroyOnLoad(canvasObject); // Place on the UI layer so that game-world cameras cannot accidentally render it. - canvasObject.layer = 5; // Unity built-in "UI" layer + canvasObject.layer = UiLayer; var canvas = canvasObject.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = overlayCamera; canvas.sortingOrder = 100; - _canvas = canvas; var textObject = new GameObject("BOP_SubtitleText"); + textObject.layer = UiLayer; textObject.transform.SetParent(canvasObject.transform, false); var rect = textObject.AddComponent(); @@ -544,6 +553,7 @@ private void ApplyOutline(TextMeshProUGUI text, Color outlineColor, float outlin } _karaokeLayer = new GameObject("BOP_KaraokeLayer"); + _karaokeLayer.layer = UiLayer; _karaokeLayer.transform.SetParent(_tmpText.transform, false); var rect = _karaokeLayer.AddComponent(); rect.anchorMin = new Vector2(0f, 0f); @@ -563,6 +573,7 @@ private void ApplyOutline(TextMeshProUGUI text, Color outlineColor, float outlin } var rootGo = new GameObject($"KaraokeSeg_{_karaokeTextPool.Count:D2}"); + rootGo.layer = UiLayer; rootGo.transform.SetParent(_karaokeLayer.transform, false); var rootRect = rootGo.AddComponent(); rootRect.anchorMin = new Vector2(0.5f, 0.5f); @@ -571,6 +582,7 @@ private void ApplyOutline(TextMeshProUGUI text, Color outlineColor, float outlin rootRect.anchoredPosition = Vector2.zero; var baseGo = new GameObject("Base"); + baseGo.layer = UiLayer; baseGo.transform.SetParent(rootGo.transform, false); var baseRect = baseGo.AddComponent(); baseRect.anchorMin = new Vector2(0f, 0f); @@ -587,6 +599,7 @@ private void ApplyOutline(TextMeshProUGUI text, Color outlineColor, float outlin } var maskGo = new GameObject("FillMask"); + maskGo.layer = UiLayer; maskGo.transform.SetParent(rootGo.transform, false); var maskRect = maskGo.AddComponent(); maskRect.anchorMin = new Vector2(0f, 0f); @@ -596,6 +609,7 @@ private void ApplyOutline(TextMeshProUGUI text, Color outlineColor, float outlin maskGo.AddComponent(); var fillGo = new GameObject("Fill"); + fillGo.layer = UiLayer; fillGo.transform.SetParent(maskGo.transform, false); var fillRect = fillGo.AddComponent(); fillRect.anchorMin = new Vector2(0f, 0f);