Skip to content
Draft
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
20 changes: 20 additions & 0 deletions BopVisualEffects/Core/EntityExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ namespace BopVisualEffects.Core;
/// </summary>
internal static class EntityExtensions
{
/// <summary>
/// Reads a <see cref="float"/> from an entity property by <paramref name="key"/>.
/// Returns <paramref name="fallback"/> when the property is absent.
/// </summary>
/// <param name="entity">The entity to read from.</param>
/// <param name="key">Property key holding the float value.</param>
/// <param name="fallback">Value returned when the property is absent. Defaults to 0.</param>
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
};
}

/// <summary>
/// Reads a <see cref="Color"/> from an entity property by <paramref name="key"/>.
/// </summary>
Expand Down
116 changes: 103 additions & 13 deletions BopVisualEffects/Effects/Zoom/CameraZoomService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,32 @@ namespace BopVisualEffects.Effects.Zoom;
/// <summary>
/// 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 (<see cref="CameraZoomOverlay"/>)
/// in <c>OnPreCull</c>, which composes cleanly with other projection-matrix modifiers such as
/// <c>CameraFlipOverlay</c> and does not conflict with transform-based effects like Camera Shake.
/// </summary>
internal static class CameraZoomService
{
private readonly struct ZoomEntry(float factor, Vector2 focalNDC)
{
public readonly float Factor = factor;

/// <summary>
/// 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.
/// </summary>
public readonly Vector2 FocalNDC = focalNDC;
}

private sealed class ZoomState
{
public readonly bool IsOrthographic;
public readonly float BaselineOrthographicSize;
public readonly float BaselineFieldOfView;
public readonly Dictionary<object, float> Factors = [];
public readonly Dictionary<object, ZoomEntry> Entries = [];
public CameraZoomOverlay? Overlay;

public ZoomState(Camera camera)
{
Expand All @@ -28,19 +44,33 @@ public ZoomState(Camera camera)
private static readonly Dictionary<Camera, ZoomState> _states = [];

/// <summary>
/// Sets or updates the zoom factor contributed by <paramref name="key"/> on <paramref name="camera"/>
/// and immediately applies the composite result to the camera.
/// Sets or updates the zoom factor and focal point contributed by <paramref name="key"/> on
/// <paramref name="camera"/> and immediately applies the composite result.
/// The first call for a given camera captures the current camera values as the baseline.
/// </summary>
public static void SetFactor(Camera camera, object key, float factor)
/// <param name="camera">The camera to apply the zoom to.</param>
/// <param name="key">Unique key identifying this zoom contributor.</param>
/// <param name="factor">
/// Zoom factor: values below 1 zoom in, values above 1 zoom out.
/// </param>
/// <param name="focalNDC">
/// 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.
/// </param>
public static void SetFactor(Camera camera, object key, float factor, Vector2 focalNDC = default)
{
if (!_states.TryGetValue(camera, out var state))
{
state = new ZoomState(camera);
_states[camera] = state;
}

state.Factors[key] = factor;
state.Entries[key] = new ZoomEntry(factor, focalNDC);

if (!state.Overlay)
state.Overlay = camera.gameObject.AddComponent<CameraZoomOverlay>();

ApplyComposite(camera, state);
}

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -91,4 +136,49 @@ private static void RestoreBaseline(Camera camera, ZoomState state)
else
camera.fieldOfView = state.BaselineFieldOfView;
}

/// <summary>
/// Camera-attached component that applies the composite focal-point NDC translation to the
/// projection matrix in <see cref="OnPreCull"/> and restores it in <see cref="OnPostRender"/>.
/// There is at most one instance of this component per camera, managed by <see cref="CameraZoomService"/>.
/// Running at execution order 10 ensures this fires after <c>CameraFlipOverlay</c> (order 0),
/// so the focal-point shift is applied on top of any flip that is already in effect.
/// </summary>
[DefaultExecutionOrder(10)]
private sealed class CameraZoomOverlay : MonoBehaviour
{
private Camera? _camera;
private Vector2 _ndcOffset;

/// <summary>
/// Updates the composite NDC offset. Called by <see cref="CameraZoomService"/> whenever the zoom state changes.
/// </summary>
public void SetNDCOffset(Vector2 offset) => _ndcOffset = offset;

private void Awake() => _camera = GetComponent<Camera>();

// 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();
}
}
}
18 changes: 14 additions & 4 deletions BopVisualEffects/Effects/ZoomIn/ZoomInEffect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ public MixtapeEventTemplate CreateTemplate(string pluginGuid)
resizable = true,
properties = new Dictionary<string, object>
{
["intensity"] = 0.2f
["intensity"] = 0.2f,
["focal_x"] = 0.0f,
["focal_y"] = 0.0f
}
};
}
Expand All @@ -43,6 +45,8 @@ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader)
var log = ClassLogger.GetForClass<ZoomInEffect>();
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;

Expand All @@ -53,14 +57,16 @@ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader)
void SpawnAction()
{
EffectRuntimeController.Instance.SpawnRunner<ZoomInRunner>(runner =>
runner.Initialize(loader, loader.jukebox, startBeat, endBeat, intensity));
runner.Initialize(loader, loader.jukebox, startBeat, endBeat, intensity, focalX, focalY));
}
}

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;
Expand All @@ -70,13 +76,15 @@ private sealed class ZoomInRunner : MonoBehaviour
/// <summary>
/// Initializes this runner with effect parameters.
/// </summary>
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();
}

Expand Down Expand Up @@ -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()
Expand Down
18 changes: 14 additions & 4 deletions BopVisualEffects/Effects/ZoomOut/ZoomOutEffect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ public MixtapeEventTemplate CreateTemplate(string pluginGuid)
resizable = true,
properties = new Dictionary<string, object>
{
["intensity"] = 0.2f
["intensity"] = 0.2f,
["focal_x"] = 0.0f,
["focal_y"] = 0.0f
}
};
}
Expand All @@ -43,6 +45,8 @@ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader)
var log = ClassLogger.GetForClass<ZoomOutEffect>();
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;

Expand All @@ -53,14 +57,16 @@ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader)
void SpawnAction()
{
EffectRuntimeController.Instance.SpawnRunner<ZoomOutRunner>(runner =>
runner.Initialize(loader, loader.jukebox, startBeat, endBeat, intensity));
runner.Initialize(loader, loader.jukebox, startBeat, endBeat, intensity, focalX, focalY));
}
}

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;
Expand All @@ -70,13 +76,15 @@ private sealed class ZoomOutRunner : MonoBehaviour
/// <summary>
/// Initializes this runner with effect parameters.
/// </summary>
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();
}

Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions docs/effects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand All @@ -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**
Expand Down