From 7153d93f60c373ee86a09e3d9e7c785a86d62ce9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:40:55 +0000 Subject: [PATCH 1/3] Initial plan From c4d658f353a3e04b66b25e661972a37a12012046 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:00:30 +0000 Subject: [PATCH 2/3] Add focal_x/focal_y coordinates to zoom effects - Extend CameraZoomService to accept a focal point (NDC Vector2) per entry. Compute composite NDC translation as sum of per-entry focalNDC*(1-1/factor). Add CameraZoomOverlay MonoBehaviour (DefaultExecutionOrder 10) that applies the projection-matrix NDC shift in OnPreCull after CameraFlipOverlay (order 0), composing cleanly with flip effects and not conflicting with transform-based effects like Camera Shake. - Update ZoomInEffect and ZoomOutEffect to add focal_x=0.5 / focal_y=0.5 template properties, read them with a fallback (backwards compat), convert to NDC, and pass to CameraZoomService.SetFactor. - Add EntityExtensions.GetFloat(key, fallback) for reading optional properties absent in events created before this change. - Document focal_x/focal_y in docs/effects/README.md. Co-authored-by: Brollyy <12004018+Brollyy@users.noreply.github.com> --- BopVisualEffects/Core/EntityExtensions.cs | 20 +++ .../Effects/Zoom/CameraZoomService.cs | 116 ++++++++++++++++-- .../Effects/ZoomIn/ZoomInEffect.cs | 20 ++- .../Effects/ZoomOut/ZoomOutEffect.cs | 20 ++- docs/effects/README.md | 4 + 5 files changed, 159 insertions(+), 21 deletions(-) diff --git a/BopVisualEffects/Core/EntityExtensions.cs b/BopVisualEffects/Core/EntityExtensions.cs index e885edd..f9cacd7 100644 --- a/BopVisualEffects/Core/EntityExtensions.cs +++ b/BopVisualEffects/Core/EntityExtensions.cs @@ -8,6 +8,26 @@ namespace BopVisualEffects.Core; /// internal static class EntityExtensions { + /// + /// Reads a from an entity property by . + /// Returns when the property is absent. + /// + /// The entity to read from. + /// Property key holding the float value. + /// Value returned when the property is absent. Defaults to 0. + public static float GetFloat(this Entity entity, string key, float fallback) + { + if (!entity.dynamicData.TryGetValue(key, out var value) || value is null) + return fallback; + return value switch + { + float f => f, + double d => (float)d, + int i => i, + _ => fallback + }; + } + /// /// Reads a from an entity property by . /// diff --git a/BopVisualEffects/Effects/Zoom/CameraZoomService.cs b/BopVisualEffects/Effects/Zoom/CameraZoomService.cs index f6733de..a8893d6 100644 --- a/BopVisualEffects/Effects/Zoom/CameraZoomService.cs +++ b/BopVisualEffects/Effects/Zoom/CameraZoomService.cs @@ -6,16 +6,32 @@ namespace BopVisualEffects.Effects.Zoom; /// /// Shared per-camera coordinator for zoom effects (Zoom In, Zoom Out, Zoom Pulse). /// Composites multiple concurrent zoom factor requests multiplicatively against the camera's -/// baseline size, so that any combination of zoom effects composes correctly. +/// baseline size, and sums the per-entry NDC focal-point offsets so that any combination of +/// zoom effects — including those with different zoom targets — composes correctly. +/// The focal-point translation is applied via a projection-matrix overlay () +/// in OnPreCull, which composes cleanly with other projection-matrix modifiers such as +/// CameraFlipOverlay and does not conflict with transform-based effects like Camera Shake. /// internal static class CameraZoomService { + private readonly struct ZoomEntry(float factor, Vector2 focalNDC) + { + public readonly float Factor = factor; + + /// + /// Focal point in NDC space: (0, 0) = screen center, (±1, ±1) = screen corners. + /// The zoom will keep this point stationary as the camera size changes. + /// + public readonly Vector2 FocalNDC = focalNDC; + } + private sealed class ZoomState { public readonly bool IsOrthographic; public readonly float BaselineOrthographicSize; public readonly float BaselineFieldOfView; - public readonly Dictionary Factors = []; + public readonly Dictionary Entries = []; + public CameraZoomOverlay? Overlay; public ZoomState(Camera camera) { @@ -28,11 +44,21 @@ public ZoomState(Camera camera) private static readonly Dictionary _states = []; /// - /// Sets or updates the zoom factor contributed by on - /// and immediately applies the composite result to the camera. + /// Sets or updates the zoom factor and focal point contributed by on + /// and immediately applies the composite result. /// The first call for a given camera captures the current camera values as the baseline. /// - public static void SetFactor(Camera camera, object key, float factor) + /// The camera to apply the zoom to. + /// Unique key identifying this zoom contributor. + /// + /// Zoom factor: values below 1 zoom in, values above 1 zoom out. + /// + /// + /// Focal point in NDC space — (0, 0) is the screen center, (±1, ±1) are the corners. + /// The zoom keeps this screen point stationary as the size changes. + /// Defaults to (0, 0) (screen center) for backwards compatibility. + /// + public static void SetFactor(Camera camera, object key, float factor, Vector2 focalNDC = default) { if (!_states.TryGetValue(camera, out var state)) { @@ -40,7 +66,11 @@ public static void SetFactor(Camera camera, object key, float factor) _states[camera] = state; } - state.Factors[key] = factor; + state.Entries[key] = new ZoomEntry(factor, focalNDC); + + if (!state.Overlay) + state.Overlay = camera.gameObject.AddComponent(); + ApplyComposite(camera, state); } @@ -53,11 +83,16 @@ public static void RemoveFactor(Camera camera, object key) if (!_states.TryGetValue(camera, out var state)) return; - state.Factors.Remove(key); + state.Entries.Remove(key); - if (state.Factors.Count == 0) + if (state.Entries.Count == 0) { RestoreBaseline(camera, state); + if (state.Overlay) + { + state.Overlay.SetNDCOffset(Vector2.zero); + Object.Destroy(state.Overlay); + } _states.Remove(camera); } else @@ -71,14 +106,24 @@ private static void ApplyComposite(Camera camera, ZoomState state) if (!camera) return; - var composite = 1f; - foreach (var f in state.Factors.Values) - composite *= f; + var compositeZoom = 1f; + var compositeNDCOffset = Vector2.zero; + foreach (var entry in state.Entries.Values) + { + compositeZoom *= entry.Factor; + // Each entry's NDC contribution: focalNDC * (1 - 1/factor). + // This is the projection-space shift needed to keep entry.FocalNDC stationary after + // the zoom factor changes the camera size. Contributions are summed across all entries. + compositeNDCOffset += entry.FocalNDC * (1f - 1f / entry.Factor); + } if (state.IsOrthographic) - camera.orthographicSize = state.BaselineOrthographicSize * composite; + camera.orthographicSize = state.BaselineOrthographicSize * compositeZoom; else - camera.fieldOfView = Mathf.Min(state.BaselineFieldOfView * composite, 179f); + camera.fieldOfView = Mathf.Min(state.BaselineFieldOfView * compositeZoom, 179f); + + if (state.Overlay) + state.Overlay.SetNDCOffset(compositeNDCOffset); } private static void RestoreBaseline(Camera camera, ZoomState state) @@ -91,4 +136,49 @@ private static void RestoreBaseline(Camera camera, ZoomState state) else camera.fieldOfView = state.BaselineFieldOfView; } + + /// + /// Camera-attached component that applies the composite focal-point NDC translation to the + /// projection matrix in and restores it in . + /// There is at most one instance of this component per camera, managed by . + /// Running at execution order 10 ensures this fires after CameraFlipOverlay (order 0), + /// so the focal-point shift is applied on top of any flip that is already in effect. + /// + [DefaultExecutionOrder(10)] + private sealed class CameraZoomOverlay : MonoBehaviour + { + private Camera? _camera; + private Vector2 _ndcOffset; + + /// + /// Updates the composite NDC offset. Called by whenever the zoom state changes. + /// + public void SetNDCOffset(Vector2 offset) => _ndcOffset = offset; + + private void Awake() => _camera = GetComponent(); + + // Fires just before the camera culls the scene — apply the focal-point translation on top + // of whatever projection matrix is already in effect this frame (e.g. flip from CameraFlipOverlay). + private void OnPreCull() + { + if (!_camera || _ndcOffset == Vector2.zero) + return; + + var offsetMatrix = Matrix4x4.identity; + offsetMatrix.m03 = _ndcOffset.x; + offsetMatrix.m13 = _ndcOffset.y; + _camera.projectionMatrix = offsetMatrix * _camera.projectionMatrix; + } + + // Fires after the camera finishes rendering — restore natural projection for next frame. + private void OnPostRender() => ResetProjection(); + + private void OnDisable() => ResetProjection(); + + private void ResetProjection() + { + if (_camera) + _camera.ResetProjectionMatrix(); + } + } } diff --git a/BopVisualEffects/Effects/ZoomIn/ZoomInEffect.cs b/BopVisualEffects/Effects/ZoomIn/ZoomInEffect.cs index 403b489..64048ee 100644 --- a/BopVisualEffects/Effects/ZoomIn/ZoomInEffect.cs +++ b/BopVisualEffects/Effects/ZoomIn/ZoomInEffect.cs @@ -32,7 +32,9 @@ public MixtapeEventTemplate CreateTemplate(string pluginGuid) resizable = true, properties = new Dictionary { - ["intensity"] = 0.2f + ["intensity"] = 0.2f, + ["focal_x"] = 0.5f, + ["focal_y"] = 0.5f } }; } @@ -43,6 +45,8 @@ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader) var log = ClassLogger.GetForClass(); var durationBeats = Mathf.Max(0.01f, entity.length); var intensity = entity.GetFloat("intensity"); + var focalX = entity.GetFloat("focal_x", 0.5f); + var focalY = entity.GetFloat("focal_y", 0.5f); var startBeat = entity.beat; var endBeat = startBeat + durationBeats; @@ -53,7 +57,7 @@ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader) void SpawnAction() { EffectRuntimeController.Instance.SpawnRunner(runner => - runner.Initialize(loader, loader.jukebox, startBeat, endBeat, intensity)); + runner.Initialize(loader, loader.jukebox, startBeat, endBeat, intensity, focalX, focalY)); } } @@ -61,6 +65,8 @@ private sealed class ZoomInRunner : MonoBehaviour { private bool _initialized; private float _intensity; + private float _focalX; + private float _focalY; private float _startBeat; private float _endBeat; private MixtapeLoaderCustom? _loader; @@ -70,13 +76,15 @@ private sealed class ZoomInRunner : MonoBehaviour /// /// Initializes this runner with effect parameters. /// - public void Initialize(MixtapeLoaderCustom loader, JukeboxScript? jukebox, float startBeat, float endBeat, float intensity) + public void Initialize(MixtapeLoaderCustom loader, JukeboxScript? jukebox, float startBeat, float endBeat, float intensity, float focalX, float focalY) { _loader = loader; _jukebox = jukebox; _startBeat = startBeat; _endBeat = endBeat; _intensity = Mathf.Clamp(intensity, 0f, 0.99f); + _focalX = focalX; + _focalY = focalY; InitializeTargetCamera(); } @@ -124,7 +132,11 @@ private void LateUpdate() // Zoom in: zoomFactor < 1 means smaller camera size = more zoomed in. var zoomFactor = Mathf.Clamp(1f - _intensity * envelope, 0.01f, 1f); - CameraZoomService.SetFactor(_camera!, this, zoomFactor); + + // Convert normalized screen coordinates [0, 1] to NDC [-1, 1]: + // (0.5, 0.5) → (0, 0) = screen center (default, backwards-compatible). + var focalNDC = new Vector2((_focalX - 0.5f) * 2f, (_focalY - 0.5f) * 2f); + CameraZoomService.SetFactor(_camera!, this, zoomFactor, focalNDC); } private void OnDisable() diff --git a/BopVisualEffects/Effects/ZoomOut/ZoomOutEffect.cs b/BopVisualEffects/Effects/ZoomOut/ZoomOutEffect.cs index ff25b25..4ff610c 100644 --- a/BopVisualEffects/Effects/ZoomOut/ZoomOutEffect.cs +++ b/BopVisualEffects/Effects/ZoomOut/ZoomOutEffect.cs @@ -32,7 +32,9 @@ public MixtapeEventTemplate CreateTemplate(string pluginGuid) resizable = true, properties = new Dictionary { - ["intensity"] = 0.2f + ["intensity"] = 0.2f, + ["focal_x"] = 0.5f, + ["focal_y"] = 0.5f } }; } @@ -43,6 +45,8 @@ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader) var log = ClassLogger.GetForClass(); var durationBeats = Mathf.Max(0.01f, entity.length); var intensity = entity.GetFloat("intensity"); + var focalX = entity.GetFloat("focal_x", 0.5f); + var focalY = entity.GetFloat("focal_y", 0.5f); var startBeat = entity.beat; var endBeat = startBeat + durationBeats; @@ -53,7 +57,7 @@ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader) void SpawnAction() { EffectRuntimeController.Instance.SpawnRunner(runner => - runner.Initialize(loader, loader.jukebox, startBeat, endBeat, intensity)); + runner.Initialize(loader, loader.jukebox, startBeat, endBeat, intensity, focalX, focalY)); } } @@ -61,6 +65,8 @@ private sealed class ZoomOutRunner : MonoBehaviour { private bool _initialized; private float _intensity; + private float _focalX; + private float _focalY; private float _startBeat; private float _endBeat; private MixtapeLoaderCustom? _loader; @@ -70,13 +76,15 @@ private sealed class ZoomOutRunner : MonoBehaviour /// /// Initializes this runner with effect parameters. /// - public void Initialize(MixtapeLoaderCustom loader, JukeboxScript? jukebox, float startBeat, float endBeat, float intensity) + public void Initialize(MixtapeLoaderCustom loader, JukeboxScript? jukebox, float startBeat, float endBeat, float intensity, float focalX, float focalY) { _loader = loader; _jukebox = jukebox; _startBeat = startBeat; _endBeat = endBeat; _intensity = Mathf.Max(0f, intensity); + _focalX = focalX; + _focalY = focalY; InitializeTargetCamera(); } @@ -124,7 +132,11 @@ private void LateUpdate() // Zoom out: zoomFactor > 1 means larger camera size = more zoomed out. var zoomFactor = 1f + _intensity * envelope; - CameraZoomService.SetFactor(_camera!, this, zoomFactor); + + // Convert normalized screen coordinates [0, 1] to NDC [-1, 1]: + // (0.5, 0.5) → (0, 0) = screen center (default, backwards-compatible). + var focalNDC = new Vector2((_focalX - 0.5f) * 2f, (_focalY - 0.5f) * 2f); + CameraZoomService.SetFactor(_camera!, this, zoomFactor, focalNDC); } private void OnDisable() diff --git a/docs/effects/README.md b/docs/effects/README.md index f9cb92a..c318bd4 100644 --- a/docs/effects/README.md +++ b/docs/effects/README.md @@ -228,6 +228,8 @@ Eases the camera smoothly into a zoomed-in view, holds at the target level, then **Properties** - `intensity`: How far to zoom in, as a fraction of the camera's base size (e.g. `0.2` = 20% closer). Clamped to `[0, 0.99]`. +- `focal_x`: Horizontal position to zoom towards, in normalized screen coordinates (0 = left edge, 0.5 = center, 1 = right edge; default `0.5`). +- `focal_y`: Vertical position to zoom towards, in normalized screen coordinates (0 = bottom edge, 0.5 = center, 1 = top edge; default `0.5`). - `length` (event length in editor): How long the effect lasts, in beats. This event is resizable in the timeline. **Preview** @@ -244,6 +246,8 @@ Eases the camera smoothly out to a wider view, holds, then eases back in. Compos **Properties** - `intensity`: How far to zoom out, as a fraction of the camera's base size (e.g. `0.2` = 20% further out). +- `focal_x`: Horizontal position to zoom away from, in normalized screen coordinates (0 = left edge, 0.5 = center, 1 = right edge; default `0.5`). +- `focal_y`: Vertical position to zoom away from, in normalized screen coordinates (0 = bottom edge, 0.5 = center, 1 = top edge; default `0.5`). - `length` (event length in editor): How long the effect lasts, in beats. This event is resizable in the timeline. **Preview** From 31af111d3b09b104cb047acc0fc85788a1460f84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:06:54 +0000 Subject: [PATCH 3/3] Use (0, 0) as screen center for focal_x/focal_y coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Template defaults changed from 0.5 to 0.0 - GetFloat fallbacks changed from 0.5f to 0.0f - Removed the (x - 0.5) * 2 conversion; values are now passed directly as NDC - Updated docs: (0,0) = center, ±1 = screen edges Co-authored-by: Brollyy <12004018+Brollyy@users.noreply.github.com> --- BopVisualEffects/Effects/ZoomIn/ZoomInEffect.cs | 14 ++++++-------- BopVisualEffects/Effects/ZoomOut/ZoomOutEffect.cs | 14 ++++++-------- docs/effects/README.md | 8 ++++---- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/BopVisualEffects/Effects/ZoomIn/ZoomInEffect.cs b/BopVisualEffects/Effects/ZoomIn/ZoomInEffect.cs index 64048ee..6dbf1b1 100644 --- a/BopVisualEffects/Effects/ZoomIn/ZoomInEffect.cs +++ b/BopVisualEffects/Effects/ZoomIn/ZoomInEffect.cs @@ -33,8 +33,8 @@ public MixtapeEventTemplate CreateTemplate(string pluginGuid) properties = new Dictionary { ["intensity"] = 0.2f, - ["focal_x"] = 0.5f, - ["focal_y"] = 0.5f + ["focal_x"] = 0.0f, + ["focal_y"] = 0.0f } }; } @@ -45,8 +45,8 @@ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader) var log = ClassLogger.GetForClass(); var durationBeats = Mathf.Max(0.01f, entity.length); var intensity = entity.GetFloat("intensity"); - var focalX = entity.GetFloat("focal_x", 0.5f); - var focalY = entity.GetFloat("focal_y", 0.5f); + var focalX = entity.GetFloat("focal_x", 0.0f); + var focalY = entity.GetFloat("focal_y", 0.0f); var startBeat = entity.beat; var endBeat = startBeat + durationBeats; @@ -133,10 +133,8 @@ private void LateUpdate() // Zoom in: zoomFactor < 1 means smaller camera size = more zoomed in. var zoomFactor = Mathf.Clamp(1f - _intensity * envelope, 0.01f, 1f); - // Convert normalized screen coordinates [0, 1] to NDC [-1, 1]: - // (0.5, 0.5) → (0, 0) = screen center (default, backwards-compatible). - var focalNDC = new Vector2((_focalX - 0.5f) * 2f, (_focalY - 0.5f) * 2f); - CameraZoomService.SetFactor(_camera!, this, zoomFactor, focalNDC); + // focal_x/focal_y are in NDC space: (0, 0) = screen center (default), (±1, ±1) = screen corners. + CameraZoomService.SetFactor(_camera!, this, zoomFactor, new Vector2(_focalX, _focalY)); } private void OnDisable() diff --git a/BopVisualEffects/Effects/ZoomOut/ZoomOutEffect.cs b/BopVisualEffects/Effects/ZoomOut/ZoomOutEffect.cs index 4ff610c..74d3933 100644 --- a/BopVisualEffects/Effects/ZoomOut/ZoomOutEffect.cs +++ b/BopVisualEffects/Effects/ZoomOut/ZoomOutEffect.cs @@ -33,8 +33,8 @@ public MixtapeEventTemplate CreateTemplate(string pluginGuid) properties = new Dictionary { ["intensity"] = 0.2f, - ["focal_x"] = 0.5f, - ["focal_y"] = 0.5f + ["focal_x"] = 0.0f, + ["focal_y"] = 0.0f } }; } @@ -45,8 +45,8 @@ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader) var log = ClassLogger.GetForClass(); var durationBeats = Mathf.Max(0.01f, entity.length); var intensity = entity.GetFloat("intensity"); - var focalX = entity.GetFloat("focal_x", 0.5f); - var focalY = entity.GetFloat("focal_y", 0.5f); + var focalX = entity.GetFloat("focal_x", 0.0f); + var focalY = entity.GetFloat("focal_y", 0.0f); var startBeat = entity.beat; var endBeat = startBeat + durationBeats; @@ -133,10 +133,8 @@ private void LateUpdate() // Zoom out: zoomFactor > 1 means larger camera size = more zoomed out. var zoomFactor = 1f + _intensity * envelope; - // Convert normalized screen coordinates [0, 1] to NDC [-1, 1]: - // (0.5, 0.5) → (0, 0) = screen center (default, backwards-compatible). - var focalNDC = new Vector2((_focalX - 0.5f) * 2f, (_focalY - 0.5f) * 2f); - CameraZoomService.SetFactor(_camera!, this, zoomFactor, focalNDC); + // focal_x/focal_y are in NDC space: (0, 0) = screen center (default), (±1, ±1) = screen corners. + CameraZoomService.SetFactor(_camera!, this, zoomFactor, new Vector2(_focalX, _focalY)); } private void OnDisable() diff --git a/docs/effects/README.md b/docs/effects/README.md index c318bd4..664cb73 100644 --- a/docs/effects/README.md +++ b/docs/effects/README.md @@ -228,8 +228,8 @@ Eases the camera smoothly into a zoomed-in view, holds at the target level, then **Properties** - `intensity`: How far to zoom in, as a fraction of the camera's base size (e.g. `0.2` = 20% closer). Clamped to `[0, 0.99]`. -- `focal_x`: Horizontal position to zoom towards, in normalized screen coordinates (0 = left edge, 0.5 = center, 1 = right edge; default `0.5`). -- `focal_y`: Vertical position to zoom towards, in normalized screen coordinates (0 = bottom edge, 0.5 = center, 1 = top edge; default `0.5`). +- `focal_x`: Horizontal offset from the screen center to zoom towards (-1 = left edge, 0 = center, 1 = right edge; default `0`). +- `focal_y`: Vertical offset from the screen center to zoom towards (-1 = bottom edge, 0 = center, 1 = top edge; default `0`). - `length` (event length in editor): How long the effect lasts, in beats. This event is resizable in the timeline. **Preview** @@ -246,8 +246,8 @@ Eases the camera smoothly out to a wider view, holds, then eases back in. Compos **Properties** - `intensity`: How far to zoom out, as a fraction of the camera's base size (e.g. `0.2` = 20% further out). -- `focal_x`: Horizontal position to zoom away from, in normalized screen coordinates (0 = left edge, 0.5 = center, 1 = right edge; default `0.5`). -- `focal_y`: Vertical position to zoom away from, in normalized screen coordinates (0 = bottom edge, 0.5 = center, 1 = top edge; default `0.5`). +- `focal_x`: Horizontal offset from the screen center to zoom away from (-1 = left edge, 0 = center, 1 = right edge; default `0`). +- `focal_y`: Vertical offset from the screen center to zoom away from (-1 = bottom edge, 0 = center, 1 = top edge; default `0`). - `length` (event length in editor): How long the effect lasts, in beats. This event is resizable in the timeline. **Preview**