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
51 changes: 49 additions & 2 deletions BopSubtitleReader/Core/SubtitleRuntimeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -90,6 +92,7 @@ public void StopSession()
_cachedActiveSegmentIndex = -1;
_cachedKaraokeBeat = float.NaN;
_cachedSubtitleCamera = null;
_cachedSubtitleViewportState = null;
_nextCameraResolveTime = 0f;
_overlay.Hide();
}
Expand Down Expand Up @@ -290,10 +293,15 @@ 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;
_overlay.SetCamera(resolvedCamera);
_cachedSubtitleViewportState = resolvedViewportState;
_overlay.SetReferenceCamera(resolvedCamera);
}

_nextCameraResolveTime = Time.unscaledTime + CameraResolveIntervalSeconds;
Expand Down Expand Up @@ -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);
}
}

}
70 changes: 45 additions & 25 deletions BopSubtitleReader/Core/TmpSubtitleOverlay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ namespace BopSubtitleReader.Core;
public sealed class TmpSubtitleOverlay
{
private static readonly ClassLogger Log = ClassLogger.GetForClass<TmpSubtitleOverlay>();
private const int UiLayer = 5; // Unity built-in "UI" layer

private bool _initializationAttempted;
private bool _available;
private GameObject? _host;
private Canvas? _canvas;
private Camera? _overlayCamera;
private TextMeshProUGUI? _tmpText;
private RectTransform? _tmpRect;
private GameObject? _karaokeLayer;
Expand Down Expand Up @@ -121,37 +122,33 @@ public void Hide()
}
}

public void SetCamera(Camera? camera)
/// <summary>
/// Syncs the subtitle overlay camera's viewport to match <paramref name="referenceCamera"/>'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 &amp; Bops editor).
/// Pass <c>null</c> to reset to a full-screen viewport.
/// </summary>
public void SetReferenceCamera(Camera? referenceCamera)
{
if (!EnsureInitialized() || _canvas is null)
if (!EnsureInitialized() || _overlayCamera is null)
{
return;
}

if (camera is not null)
if (referenceCamera is null)
{
if (_canvas.renderMode != RenderMode.ScreenSpaceCamera)
{
_canvas.renderMode = RenderMode.ScreenSpaceCamera;
}

if (!ReferenceEquals(_canvas.worldCamera, camera))
{
_canvas.worldCamera = camera;
}

_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;
}

if (_canvas.renderMode != RenderMode.ScreenSpaceOverlay)
{
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
}

if (_canvas.worldCamera is not null)
{
_canvas.worldCamera = null;
}
// 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;
}
Comment thread
Brollyy marked this conversation as resolved.

private bool EnsureInitialized()
Expand All @@ -168,15 +165,33 @@ 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<Camera>();
overlayCamera.clearFlags = CameraClearFlags.Depth;
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);
_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 = UiLayer;

var canvas = canvasObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
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<RectTransform>();
Expand Down Expand Up @@ -538,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<RectTransform>();
rect.anchorMin = new Vector2(0f, 0f);
Expand All @@ -557,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<RectTransform>();
rootRect.anchorMin = new Vector2(0.5f, 0.5f);
Expand All @@ -565,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<RectTransform>();
baseRect.anchorMin = new Vector2(0f, 0f);
Expand All @@ -581,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<RectTransform>();
maskRect.anchorMin = new Vector2(0f, 0f);
Expand All @@ -590,6 +609,7 @@ private void ApplyOutline(TextMeshProUGUI text, Color outlineColor, float outlin
maskGo.AddComponent<RectMask2D>();

var fillGo = new GameObject("Fill");
fillGo.layer = UiLayer;
fillGo.transform.SetParent(maskGo.transform, false);
var fillRect = fillGo.AddComponent<RectTransform>();
fillRect.anchorMin = new Vector2(0f, 0f);
Expand Down
Loading