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..6dbf1b1 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.0f,
+ ["focal_y"] = 0.0f
}
};
}
@@ -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.0f);
+ var focalY = entity.GetFloat("focal_y", 0.0f);
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,9 @@ 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);
+
+ // 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 ff25b25..74d3933 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.0f,
+ ["focal_y"] = 0.0f
}
};
}
@@ -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.0f);
+ var focalY = entity.GetFloat("focal_y", 0.0f);
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,9 @@ private void LateUpdate()
// Zoom out: zoomFactor > 1 means larger camera size = more zoomed out.
var zoomFactor = 1f + _intensity * envelope;
- CameraZoomService.SetFactor(_camera!, this, zoomFactor);
+
+ // 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 f9cb92a..664cb73 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 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**
@@ -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 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**