using UnityEngine; namespace Unity.Cinemachine { /// /// An add-on module for Cinemachine Camera that adjusts the framing if the tracking /// target implements ICinemachineTargetGroup. /// /// An attempt will be made to fit the entire target group within the specified framing. /// Camera position and/or rotation may be adjusted, depending on the settings. /// [AddComponentMenu("Cinemachine/Procedural/Extensions/Cinemachine Group Framing")] [ExecuteAlways] [SaveDuringPlay] [RequiredTarget(RequiredTargetAttribute.RequiredTargets.GroupLookAt)] [HelpURL(Documentation.BaseURL + "manual/CinemachineGroupFraming.html")] public class CinemachineGroupFraming : CinemachineExtension { /// What screen dimensions to consider when framing public enum FramingModes { /// Consider only the horizontal dimension. Vertical framing is ignored. Horizontal, /// Consider only the vertical dimension. Horizontal framing is ignored. Vertical, /// The larger of the horizontal and vertical dimensions will dominate, to get the best fit. HorizontalAndVertical }; /// What screen dimensions to consider when framing [Tooltip("What screen dimensions to consider when framing. Can be Horizontal, Vertical, or both")] public FramingModes FramingMode = FramingModes.HorizontalAndVertical; /// How much of the screen to fill with the bounding box of the targets. [Tooltip("The bounding box of the targets should occupy this amount of the screen space. " + "1 means fill the whole screen. 0.5 means fill half the screen, etc.")] [Range(0, 2)] public float FramingSize = 0.8f; /// A nonzero value will offset the group in the camera frame. [Tooltip("A nonzero value will offset the group in the camera frame.")] public Vector2 CenterOffset = Vector2.zero; /// How aggressively the camera tries to frame the group. /// Small numbers are more responsive [Range(0, 20)] [Tooltip("How aggressively the camera tries to frame the group. Small numbers are more responsive, " + "rapidly adjusting the camera to keep the group in the frame. Larger numbers give a heavier " + "more slowly responding camera.")] public float Damping = 2f; /// How to adjust the camera to get the desired framing size public enum SizeAdjustmentModes { /// Do not move the camera, only adjust the FOV. ZoomOnly, /// Just move the camera, don't change the FOV. DollyOnly, /// Move the camera as much as permitted by the ranges, then /// adjust the FOV if necessary to make the shot. DollyThenZoom }; /// How to adjust the camera to get the desired framing [Tooltip("How to adjust the camera to get the desired framing size. You can zoom, dolly in/out, or do both.")] public SizeAdjustmentModes SizeAdjustment = SizeAdjustmentModes.DollyThenZoom; /// How to adjust the camera to get the desired horizontal and vertical framing public enum LateralAdjustmentModes { /// Do not rotate the camera to reframe, only change the position. ChangePosition, /// Rotate the camera to reframe, do not change the position. ChangeRotation }; /// How to adjust the camera to get the desired horizontal and vertical framing [Tooltip("How to adjust the camera to get the desired horizontal and vertical framing.")] public LateralAdjustmentModes LateralAdjustment = LateralAdjustmentModes.ChangePosition; /// Allowable FOV range, if adjusting FOV [Tooltip("Allowable FOV range, if adjusting FOV.")] [MinMaxRangeSlider(1, 179)] public Vector2 FovRange = new (1, 100); /// Allowable range for the camera to move. 0 is the undollied position. /// Negative values move the camera closer to the target. [Tooltip("Allowable range for the camera to move. 0 is the undollied position. " + "Negative values move the camera closer to the target.")] [Vector2AsRange] public Vector2 DollyRange = new (-100, 100); /// Allowable orthographic size range, if adjusting orthographic size [Tooltip("Allowable orthographic size range, if adjusting orthographic size.")] [Vector2AsRange] public Vector2 OrthoSizeRange = new Vector2(1, 1000); const float k_MinimumGroupSize = 0.01f; void OnValidate() { FramingSize = Mathf.Max(k_MinimumGroupSize, FramingSize); Damping = Mathf.Max(0, Damping); DollyRange.y = Mathf.Max(DollyRange.x, DollyRange.y); FovRange.y = Mathf.Clamp(FovRange.y, 1, 179); FovRange.x = Mathf.Clamp(FovRange.x, 1, FovRange.y); OrthoSizeRange.x = Mathf.Max(0.01f, OrthoSizeRange.x); OrthoSizeRange.y = Mathf.Max(OrthoSizeRange.x, OrthoSizeRange.y); } void Reset() { FramingMode = FramingModes.HorizontalAndVertical; SizeAdjustment = SizeAdjustmentModes.DollyThenZoom; LateralAdjustment = LateralAdjustmentModes.ChangePosition; FramingSize = 0.8f; CenterOffset = Vector2.zero; Damping = 2; DollyRange = new Vector2(-100, 100); FovRange = new Vector2(1, 100); OrthoSizeRange = new Vector2(1, 1000); } /// For editor visualization of the calculated bounding box of the group internal Bounds GroupBounds; /// For editor visualization of the calculated bounding box of the group internal Matrix4x4 GroupBoundsMatrix; class VcamExtraState : VcamExtraStateBase { public Vector3 PosAdjustment; public Vector2 RotAdjustment; public float FovAdjustment; public void Reset() { PosAdjustment = Vector3.zero; RotAdjustment = Vector2.zero; FovAdjustment = 0; } }; /// /// Report maximum damping time needed for this extension. /// Only used in editor for timeline scrubbing. /// /// Highest damping setting in this extension public override float GetMaxDampTime() => Damping; /// Callback to tweak the settings /// The virtual camera being processed /// The current pipeline stage /// The current virtual camera state /// The current applicable deltaTime protected override void PostPipelineStageCallback( CinemachineVirtualCameraBase vcam, CinemachineCore.Stage stage, ref CameraState state, float deltaTime) { // We have to do it after both Body and Aim, and the only way to ensure that is to // do it after noise (because body and aim can be inverted). // We ignore the noise effect anyway, so it doesn't hurt. if (stage != CinemachineCore.Stage.Noise) return; var group = vcam.LookAtTargetAsGroup; group ??= vcam.FollowTargetAsGroup; if (group == null || !group.IsValid) return; var extra = GetExtraState(vcam); if (!vcam.PreviousStateIsValid) extra.Reset(); if (state.Lens.Orthographic) OrthoFraming(vcam, group, extra, ref state, deltaTime); else PerspectiveFraming(vcam, group, extra, ref state, deltaTime); } void OrthoFraming( CinemachineVirtualCameraBase vcam, ICinemachineTargetGroup group, VcamExtraState extra, ref CameraState state, float deltaTime) { var damping = vcam.PreviousStateIsValid && deltaTime >= 0 ? Damping : 0; // Position adjustment: work in camera-local coords GroupBoundsMatrix = Matrix4x4.TRS(state.RawPosition, state.RawOrientation, Vector3.one); GroupBounds = group.GetViewSpaceBoundingBox(GroupBoundsMatrix, true); var camPos = GroupBounds.center; camPos.z = Mathf.Min(0, camPos.z - GroupBounds.extents.z); // Ortho size adjustment var lens = state.Lens; var targetHeight = GetFrameHeight(GroupBounds.size / FramingSize, lens.Aspect) * 0.5f; targetHeight = Mathf.Clamp(targetHeight, OrthoSizeRange.x, OrthoSizeRange.y); var deltaFov = targetHeight - lens.OrthographicSize; extra.FovAdjustment += vcam.DetachedFollowTargetDamp(deltaFov - extra.FovAdjustment, damping, deltaTime); lens.OrthographicSize += extra.FovAdjustment; camPos.x -= CenterOffset.x * lens.OrthographicSize / lens.Aspect; camPos.y -= CenterOffset.y * lens.OrthographicSize; extra.PosAdjustment += vcam.DetachedFollowTargetDamp(camPos - extra.PosAdjustment, damping, deltaTime); state.PositionCorrection += state.RawOrientation * extra.PosAdjustment; state.Lens = lens; } void PerspectiveFraming( CinemachineVirtualCameraBase vcam, ICinemachineTargetGroup group, VcamExtraState extra, ref CameraState state, float deltaTime) { var damping = vcam.PreviousStateIsValid && deltaTime >= 0 ? Damping : 0; var camPos = state.RawPosition; var camRot = state.RawOrientation; var up = camRot * Vector3.up; var fov = state.Lens.FieldOfView; // Get a naive bounds for the group, and pull the camera out as far as we can // to see as many members as possible. Group members behind the camera will be ignored. var canDollyOut = SizeAdjustment != SizeAdjustmentModes.ZoomOnly; var dollyRange = canDollyOut ? DollyRange : Vector2.zero; var m = Matrix4x4.TRS(camPos, camRot, Vector3.one); var b = group.GetViewSpaceBoundingBox(m, canDollyOut); var moveCamera = LateralAdjustment == LateralAdjustmentModes.ChangePosition; if (!moveCamera) { // Set up the initial rotation var fwd = m.MultiplyPoint3x4(b.center) - camPos; if (!Vector3.Cross(fwd, up).AlmostZero()) camRot = Quaternion.LookRotation(fwd, up); } const float slush = 5; // avoid the members getting too close to the camera var dollyAmount = Mathf.Clamp(Mathf.Min(0, b.center.z) - b.extents.z - slush, dollyRange.x, dollyRange.y); camPos += camRot * new Vector3(0, 0, dollyAmount); // Approximate looking at the group center, then correct for actual center ComputeCameraViewGroupBounds(group, ref camPos, ref camRot, moveCamera); AdjustSize(group, state.Lens.Aspect, ref camPos, ref camRot, ref fov, ref dollyAmount); // Apply the adjustments var lens = state.Lens; var deltaFov = fov - lens.FieldOfView; extra.FovAdjustment += vcam.DetachedFollowTargetDamp(deltaFov - extra.FovAdjustment, damping, deltaTime); lens.FieldOfView += extra.FovAdjustment; state.Lens = lens; var deltaRot = state.RawOrientation.GetCameraRotationToTarget(camRot * Vector3.forward, up); extra.RotAdjustment.x += vcam.DetachedFollowTargetDamp(deltaRot.x - extra.RotAdjustment.x, damping, deltaTime); extra.RotAdjustment.y += vcam.DetachedFollowTargetDamp(deltaRot.y - extra.RotAdjustment.y, damping, deltaTime); state.OrientationCorrection = state.OrientationCorrection * Quaternion.identity.ApplyCameraRotation(extra.RotAdjustment, up); var deltaPos = Quaternion.Inverse(state.RawOrientation) * (camPos - state.RawPosition); extra.PosAdjustment += vcam.DetachedFollowTargetDamp(deltaPos - extra.PosAdjustment, damping, deltaTime); state.PositionCorrection += state.RawOrientation * extra.PosAdjustment; // Apply framing offset if (Mathf.Abs(CenterOffset.x) > 0.01f ||Mathf.Abs(CenterOffset.y) > 0.01f) { var halfFov = 0.5f * state.Lens.FieldOfView; if (moveCamera) { var d = GroupBounds.center.z - GroupBounds.extents.z; state.PositionCorrection -= state.RawOrientation * new Vector3( CenterOffset.x * Mathf.Tan(halfFov * Mathf.Deg2Rad * state.Lens.Aspect) * d, CenterOffset.y * Mathf.Tan(halfFov * Mathf.Deg2Rad) * d, 0); } else { var rot = new Vector2(CenterOffset.y * halfFov, CenterOffset.x * halfFov / state.Lens.Aspect); state.OrientationCorrection *= Quaternion.identity.ApplyCameraRotation(rot, state.ReferenceUp); } } } void AdjustSize( ICinemachineTargetGroup group, float aspect, ref Vector3 camPos, ref Quaternion camRot, ref float fov, ref float dollyAmount) { // Dolly mode: Adjust camera distance if (SizeAdjustment != SizeAdjustmentModes.ZoomOnly) { // What distance from near edge would be needed to get the desired frame height, at the current FOV var frameHeight = GetFrameHeight(GroupBounds.size / FramingSize, aspect); var currentDistance = GroupBounds.center.z - GroupBounds.extents.z; var desiredDistance = frameHeight / (2f * Mathf.Tan(fov * Mathf.Deg2Rad / 2f)); float dolly = currentDistance - desiredDistance; // Clamp to respect min/max camera movement dolly = Mathf.Clamp(dolly + dollyAmount, DollyRange.x, DollyRange.y) - dollyAmount; dollyAmount += dolly; // Because moving the camera affects the view space bounds, we recompute after movement camPos += camRot * new Vector3(0, 0, dolly); ComputeCameraViewGroupBounds(group, ref camPos, ref camRot, true); } // Zoom mode: Adjust lens if (SizeAdjustment != SizeAdjustmentModes.DollyOnly) { var frameHeight = GetFrameHeight(GroupBounds.size / FramingSize, aspect); var distance = GroupBounds.center.z - GroupBounds.extents.z; if (distance > Epsilon) fov = 2f * Mathf.Atan(frameHeight / (2 * distance)) * Mathf.Rad2Deg; fov = Mathf.Clamp(fov, FovRange.x, FovRange.y); } } /// Computes GroupBoundsMatrix and GroupBounds void ComputeCameraViewGroupBounds( ICinemachineTargetGroup group, ref Vector3 camPos, ref Quaternion camRot, bool moveCamera) { GroupBoundsMatrix = Matrix4x4.TRS(camPos, camRot, Vector3.one); // Initial naive approximation if (moveCamera) { GroupBounds = group.GetViewSpaceBoundingBox(GroupBoundsMatrix, false); var pos = GroupBounds.center; pos.z = 0; camPos = GroupBoundsMatrix.MultiplyPoint3x4(pos); GroupBoundsMatrix = Matrix4x4.TRS(camPos, camRot, Vector3.one); } group.GetViewSpaceAngularBounds(GroupBoundsMatrix, out var minAngles, out var maxAngles, out var zRange); var shift = (minAngles + maxAngles) / 2; var adjustment = Quaternion.identity.ApplyCameraRotation(shift, Vector3.up); if (moveCamera) { // We shift only in the camera XY plane - there is no Z movement. // The result is approximate - accuracy drops when there are big z differences in members. // This could be improved with multiple iterations, but it's not worth it. var dir = adjustment * Vector3.forward; new Plane(Vector3.forward, new Vector3(0, 0, zRange.x)).Raycast(new Ray(Vector3.zero, dir), out var t); camPos = dir * t; camPos.z = 0; camPos = GroupBoundsMatrix.MultiplyPoint3x4(camPos); GroupBoundsMatrix.SetColumn(3, camPos); // Account for parallax: recompute bounds after shifting position. group.GetViewSpaceAngularBounds(GroupBoundsMatrix, out minAngles, out maxAngles, out zRange); } else { // Rotate to look at center - no parallax shift to worry about camRot *= adjustment; GroupBoundsMatrix = Matrix4x4.TRS(camPos, camRot, Vector3.one); minAngles -= shift; maxAngles -= shift; } // For width and height (in camera space) of the bounding box, we use the values // at the near end of the box. The gizmo drawer will take this into account // when displaying the frustum bounds of the group Vector2 angles = new Vector2(89.5f, 89.5f); if (zRange.x > 0) { angles = Vector2.Max(maxAngles, UnityVectorExtensions.Abs(minAngles)); angles = Vector2.Min(angles, new Vector2(89.5f, 89.5f)); } var twiceNear = zRange.x * 2; angles *= Mathf.Deg2Rad; GroupBounds = new Bounds( new Vector3(0, 0, (zRange.x + zRange.y) * 0.5f), new Vector3(Mathf.Tan(angles.y) * twiceNear, Mathf.Tan(angles.x) * twiceNear, zRange.y - zRange.x)); } float GetFrameHeight(Vector2 boundsSize, float aspect) { float h; switch (FramingMode) { case FramingModes.Horizontal: h = Mathf.Max(Epsilon, boundsSize.x) / aspect; break; case FramingModes.Vertical: h = Mathf.Max(Epsilon, boundsSize.y); break; default: case FramingModes.HorizontalAndVertical: h = Mathf.Max(Mathf.Max(Epsilon, boundsSize.x) / aspect, Mathf.Max(Epsilon, boundsSize.y)); break; } return Mathf.Max(h, k_MinimumGroupSize); } } }