diff --git a/src/camera/mod.rs b/src/camera/mod.rs index 57c24ba..7e455b8 100644 --- a/src/camera/mod.rs +++ b/src/camera/mod.rs @@ -6,6 +6,18 @@ use crate::{fighter::manifest::FighterManifest, stage::manifest::StageCameraProf const EMPTY_FRAME_HALF_SIZE: f32 = 40.0; const SUBJECT_COUNT_SCALES: [f32; 4] = [1.5, 1.32, 1.16, 1.0]; +const VERTICAL_PAN_REFERENCE_OFFSET: f32 = -30.0; +const EXTENT_APPROACH_PER_SECOND: f32 = 30.0; +const MELEE_DOWNWARD_EXPANSION_START_DEPTH: f32 = 80.0; +const MELEE_DOWNWARD_EXPANSION_END_DEPTH: f32 = 5000.0; +const MELEE_DOWNWARD_EXPANSION_MIN: f32 = 10.0; +const MELEE_DOWNWARD_EXPANSION_MAX: f32 = 400.0; +const MELEE_INTEREST_FOLLOW_MIN: f32 = 0.05; +const MELEE_INTEREST_FOLLOW_MAX: f32 = 0.1; +const MELEE_INTEREST_FOLLOW_START_SPREAD: f32 = 120.0; +const MELEE_INTEREST_FOLLOW_END_SPREAD: f32 = 900.0; +const MELEE_EYE_FOLLOW: f32 = 0.15; +const MELEE_FOV_FOLLOW: f32 = 0.1; /// The rectangle contributed by one tracked subject on the gameplay plane. #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -17,6 +29,17 @@ pub struct CameraSubjectBounds { pub top_extent: f32, } +/// Presentation-only fighter camera extents that ease toward the manifest +/// values. This prevents a facing change from instantly expanding the frame. +#[derive(Component, Clone, Copy, Debug, Default)] +pub struct FighterCameraExtents { + left: f32, + right: f32, + bottom: f32, + top: f32, + initialized: bool, +} + /// The aggregate rectangle the camera must keep in view on the gameplay plane. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct CameraFrameBounds { @@ -87,7 +110,9 @@ impl Plugin for MatchCameraPlugin { fn build(&self, app: &mut App) { app.add_systems( Update, - (update_match_camera, debug_draw_camera).run_if(in_state(crate::AppState::InMatch)), + (update_match_camera, debug_draw_camera) + .chain() + .run_if(in_state(crate::AppState::InMatch)), ); } } @@ -141,14 +166,18 @@ pub fn frame_subjects( frame }; - let depth_fraction = ((current_depth - profile.min_depth) - / (profile.max_depth - profile.min_depth).max(f32::EPSILON)) - .clamp(0.0, 1.0); - frame.bottom = - (frame.bottom - profile.max_downward_expansion * depth_fraction).max(profile.bottom); + frame.bottom -= melee_downward_expansion(current_depth); frame } +fn melee_downward_expansion(depth: f32) -> f32 { + let fraction = ((depth.abs() - MELEE_DOWNWARD_EXPANSION_START_DEPTH) + / (MELEE_DOWNWARD_EXPANSION_END_DEPTH - MELEE_DOWNWARD_EXPANSION_START_DEPTH)) + .clamp(0.0, 1.0); + MELEE_DOWNWARD_EXPANSION_MIN + + (MELEE_DOWNWARD_EXPANSION_MAX - MELEE_DOWNWARD_EXPANSION_MIN) * fraction +} + /// Solves a perspective camera pose that contains `frame` at the supplied /// aspect ratio. The profile supplies Melee-style stage bias and pan limits. pub fn solve_pose( @@ -163,78 +192,156 @@ pub fn solve_pose( let vertical_center = profile.origin.y + ((frame.bottom - profile.origin.y) + (frame.top - profile.origin.y)) * (0.5 - vertical_bias); - let vertical_angle = - (-(vertical_center - profile.origin.y) * profile.vertical_pan_coefficient).clamp( + let vertical_angle = (-(vertical_center + VERTICAL_PAN_REFERENCE_OFFSET) + * profile.vertical_pan_degrees_per_unit) + .to_radians() + .clamp( -profile.max_downward_pan_degrees.to_radians(), profile.max_upward_pan_degrees.to_radians(), - ) + profile.vertical_tilt_degrees.to_radians(); + ) + + profile.vertical_pan_degrees.to_radians(); let up_angle = vertical_half_fov + vertical_angle; let down_angle = vertical_half_fov - vertical_angle; let vertical_depth = frame.height() / (up_angle.tan() + down_angle.tan()).max(f32::EPSILON); let vertical_eye_offset = vertical_depth * vertical_angle.tan(); let interest_y = vertical_eye_offset + frame.top - vertical_depth * up_angle.tan(); - let horizontal_half_fov = (vertical_half_fov.tan() * aspect_ratio).atan(); let horizontal_center = (frame.left + frame.right) * 0.5; - let horizontal_angle = - (-(horizontal_center - profile.origin.x) * profile.horizontal_pan_coefficient).clamp( + let horizontal_angle = (-(horizontal_center - profile.origin.x) + * profile.horizontal_pan_degrees_per_unit) + .to_radians() + .clamp( -profile.max_horizontal_pan_degrees.to_radians(), profile.max_horizontal_pan_degrees.to_radians(), ); - let right_angle = horizontal_half_fov - horizontal_angle; - let left_angle = horizontal_half_fov + horizontal_angle; - let horizontal_depth = frame.width() / (right_angle.tan() + left_angle.tan()).max(f32::EPSILON); - let horizontal_eye_offset = horizontal_depth * horizontal_angle.tan(); - let interest_x = frame.right - horizontal_depth * right_angle.tan() - horizontal_eye_offset; + let right_tangent = aspect_ratio * (vertical_half_fov - horizontal_angle).tan(); + let left_tangent = aspect_ratio * (vertical_half_fov + horizontal_angle).tan(); + let horizontal_depth = frame.width() / (right_tangent + left_tangent).max(f32::EPSILON); + let horizontal_eye_offset = aspect_ratio * horizontal_depth * horizontal_angle.tan(); + let interest_x = frame.right - horizontal_depth * right_tangent - horizontal_eye_offset; let depth = horizontal_depth .max(vertical_depth) .clamp(profile.min_depth, profile.max_depth); - let interest = Vec2::new(interest_x, interest_y).clamp( - Vec2::new(profile.left, profile.bottom), - Vec2::new(profile.right, profile.top), - ); + let interest = Vec2::new(interest_x, interest_y); - CameraPose { - eye: interest + Vec2::new(horizontal_eye_offset, -vertical_eye_offset), - interest, - depth, - fov: profile.vertical_fov_degrees.to_radians(), + correct_pose_to_camera_bounds( + CameraPose { + eye: interest + Vec2::new(horizontal_eye_offset, -vertical_eye_offset), + interest, + depth, + fov: profile.vertical_fov_degrees.to_radians(), + }, + profile, + aspect_ratio, + ) +} + +fn correct_pose_to_camera_bounds( + mut pose: CameraPose, + profile: &StageCameraProfile, + aspect_ratio: f32, +) -> CameraPose { + let Some(corners) = view_corners_on_plane(pose, aspect_ratio) else { + return pose; + }; + let mut left = f32::INFINITY; + let mut right = f32::NEG_INFINITY; + let mut bottom = f32::INFINITY; + let mut top = f32::NEG_INFINITY; + for corner in corners { + left = left.min(corner.x); + right = right.max(corner.x); + bottom = bottom.min(corner.y); + top = top.max(corner.y); } + let correction = Vec2::new( + axis_correction(left, right, profile.left, profile.right), + axis_correction(bottom, top, profile.bottom, profile.top), + ); + pose.eye += correction; + pose.interest += correction; + pose +} + +fn axis_correction(minimum: f32, maximum: f32, lower_bound: f32, upper_bound: f32) -> f32 { + let lower_overlap = (lower_bound - minimum).max(0.0); + let upper_overlap = (upper_bound - maximum).min(0.0); + match (lower_overlap > 0.0, upper_overlap < 0.0) { + (true, true) => (lower_overlap + upper_overlap) * 0.5, + (true, false) => lower_overlap, + (false, true) => upper_overlap, + (false, false) => 0.0, + } +} + +fn view_corners_on_plane(pose: CameraPose, aspect_ratio: f32) -> Option<[Vec2; 4]> { + let eye = Vec3::new(pose.eye.x, pose.eye.y, pose.depth); + let interest = Vec3::new(pose.interest.x, pose.interest.y, 0.0); + let forward = (interest - eye).try_normalize()?; + let right = forward.cross(Vec3::Y).try_normalize()?; + let up = right.cross(forward).try_normalize()?; + let vertical_tangent = (pose.fov * 0.5).tan(); + let horizontal_tangent = vertical_tangent * aspect_ratio; + [(1.0, 1.0), (-1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)] + .map(|(horizontal, vertical)| { + let ray = (forward + + right * horizontal_tangent * horizontal + + up * vertical_tangent * vertical) + .try_normalize()?; + let distance = -eye.z / ray.z; + (distance >= 0.0).then(|| (eye + ray * distance).truncate()) + }) + .into_iter() + .collect::>>() + .and_then(|corners| corners.try_into().ok()) } fn update_match_camera( time: Res