concinnity-core 0.18.69

Runtime vocabulary for the Concinnity engine: GPU layouts, ECS components, registry, CPU kernels
Documentation
// src/components/camera3d.rs
//
// Runtime 3D camera component. Its authored args and controller config live in
// this file, alongside the runtime component they bake into.

use crate::ecs::Component;
use crate::ecs::SkinnedMeshHandle;
use crate::ecs::de_opt_skinned_mesh_handle;
use alloc::string::{String, ToString};

/// How a followed character converts movement input into displacement.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FollowDrive {
    /// The controller only writes the speed parameter and the facing; the
    /// character moves by the displacement its animation clips carry (clips
    /// baked with [root_motion](animation.md)). Clips must travel along
    /// local -Z so the facing yaw and the travel direction agree.
    RootMotion,
    /// The controller moves the character capsule directly at the camera
    /// controller's `move_speed`, for characters whose clips animate in
    /// place. The speed parameter is still written, so a locomotion
    /// blendspace matches the visual gait to the travel speed.
    Direct,
}

/// Third-person follow settings carried on a [CameraController](#cameracontroller).
///
/// When `follow` is set the camera becomes a third-person orbit camera: the
/// mouse orbits around the followed character, and WASD steers the character
/// itself (camera-relative). The character must be a
/// [SkinnedMesh](skinned_mesh.md) with a `capsule`, so it has a kinematic
/// character capsule to move.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct FollowController {
    /// Name of the followed [SkinnedMesh](skinned_mesh.md). It must declare a
    /// `capsule`.
    #[serde(deserialize_with = "de_opt_skinned_mesh_handle")]
    pub target: Option<SkinnedMeshHandle>,
    /// Orbit distance from the pivot to the camera, in world units.
    pub distance: f32,
    /// Pivot height above the character's feet, in world units.
    pub height: f32,
    /// How the character moves; see [FollowDrive](#followdrive).
    pub drive: FollowDrive,
    /// Character turn rate toward the input heading, in radians per second.
    pub turn_speed: f32,
    /// Name of the character's [AnimationGraph](anim_graph.md) float parameter
    /// that receives the current travel speed in world units per second
    /// (drives a locomotion blendspace). Empty disables parameter writes,
    /// leaving the graph externally driven.
    pub speed_parameter: String,
    /// Jump apex height in world units when the jump key is pressed while
    /// grounded. `0` disables jumping.
    pub jump_height: f32,
}

impl Default for FollowController {
    fn default() -> Self {
        Self {
            target: None,
            distance: 4.0,
            height: 1.5,
            drive: FollowDrive::RootMotion,
            turn_speed: 10.0,
            speed_parameter: "speed".to_string(),
            jump_height: 0.0,
        }
    }
}

/// First-person / fly-through controller settings carried on a `Camera3D`.
///
/// A `Camera3D` whose `controller` is set (the default) is driven each frame by
/// the internal camera controller, which turns mouse/keyboard input into a
/// camera orientation and a movement intent. Set `controller` to `null` for a
/// camera driven by something else (a `CameraShot` / `Scene` cutscene).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct CameraController {
    /// Direct 6-DoF flight mode. WASD moves along the camera's full forward
    /// vector (yaw + pitch) and jump rises along world +Y; the controller
    /// writes the new position straight onto Camera3D, bypassing the physics
    /// step and the bounds box. Used for inspector / fly-through cameras (the
    /// default, e.g. the `cn add foo.glb` scaffold). Set `false` for the
    /// FPS-style ground walker.
    pub free_fly: bool,
    /// Walk / fly speed in world units per second.
    pub move_speed: f32,
    /// Sprint multiplier applied when the sprint key is held.
    pub sprint_multiplier: f32,
    /// Mouse look sensitivity in radians per pixel.
    pub mouse_sensitivity: f32,
    /// Margin kept between the camera and the bounds box (world units).
    pub player_radius: f32,
    /// AABB minimum corner the camera centre must stay inside [x, y, z].
    pub bounds_min: [f32; 3],
    /// AABB maximum corner the camera centre must stay inside [x, y, z].
    pub bounds_max: [f32; 3],
    /// Third-person follow settings; see [FollowController](#followcontroller).
    /// When set, the camera orbits the followed character and WASD steers the
    /// character instead of the camera (`free_fly` and the bounds box are
    /// ignored). `null` (the default) keeps the first-person / fly modes.
    pub follow: Option<FollowController>,
}

impl Default for CameraController {
    fn default() -> Self {
        const BIG: f32 = 1.0e9;
        Self {
            // A bare `Camera3D` is navigable out of the box as a free-fly
            // inspector: the `cn add foo.glb` scaffold relies on this. Worlds
            // that want the FPS ground walker set `free_fly: false`.
            free_fly: true,
            move_speed: 1.0,
            sprint_multiplier: 3.0,
            mouse_sensitivity: 0.0015,
            player_radius: 0.3,
            bounds_min: [-BIG, -BIG, -BIG],
            bounds_max: [BIG, BIG, BIG],
            follow: None,
        }
    }
}

// A `Camera3D` with no explicit `controller` gets the default inspector
// controller, so an authored scene is navigable out of the box.
fn default_controller() -> Option<CameraController> {
    Some(CameraController::default())
}

/// Authored fields of a `Camera3D`; the runtime view matrix and per-frame input
/// intent are not declared.
///
/// ```rust
/// # use concinnity_core::components::cook::Camera3D as Camera3DArgs;
/// Camera3DArgs {
///     fov_y_degrees: 80.0,
///     near: 0.05,
///     far: 500.0,
///     position: [0.0, 4.0, 0.0],
///     ..Default::default()
/// };
/// ```
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Camera3DArgs {
    /// Vertical field-of-view in degrees.
    pub fov_y_degrees: f32,
    /// Near clip plane distance.
    pub near: f32,
    /// Far clip plane distance.
    pub far: f32,
    /// Initial eye position in world space [x, y, z].
    pub position: [f32; 3],
    /// Initial yaw in radians (0 = looking toward -Z).
    pub yaw: f32,
    /// Initial pitch in radians.
    pub pitch: f32,
    /// Input controller settings, or `null` to leave the camera uncontrolled
    /// (driven by a [CameraShot](#camerashot) / [Scene](#scene)
    /// cutscene). Omitted defaults to a free-fly inspector controller.
    #[serde(default = "default_controller")]
    pub controller: Option<CameraController>,
}

impl Default for Camera3DArgs {
    fn default() -> Self {
        Self {
            fov_y_degrees: 75.0,
            near: 0.05,
            far: 200.0,
            position: [0.0, 1.7, 0.0],
            yaw: 0.0,
            pitch: 0.0,
            controller: default_controller(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_bare_camera_is_navigable_as_a_free_fly_inspector() {
        // The `cn add foo.glb` scaffold declares only a Camera3D, so the default
        // controller has to be the one that can fly around and look at it.
        let args = Camera3DArgs::default();
        let c = args.controller.expect("default inspector controller");
        assert!(c.free_fly);
        assert_eq!(c.move_speed, 1.0);
        assert_eq!(c.sprint_multiplier, 3.0);
        assert!(c.follow.is_none());
        assert_eq!(args.position, [0.0, 1.7, 0.0]);
        assert_eq!((args.near, args.far), (0.05, 200.0));
    }

    #[test]
    fn default_bounds_do_not_constrain_the_camera() {
        let c = CameraController::default();
        assert!(c.bounds_min.iter().all(|&v| v <= -1.0e9));
        assert!(c.bounds_max.iter().all(|&v| v >= 1.0e9));
    }

    #[test]
    fn an_omitted_controller_still_gets_the_inspector() {
        // `#[serde(default)]` on the struct would make an absent field `None`,
        // so the field carries its own default fn.
        let args: Camera3DArgs = serde_json::from_str(r#"{"fov_y_degrees":60}"#).unwrap();
        assert_eq!(args.fov_y_degrees, 60.0);
        assert!(args.controller.expect("inspector controller").free_fly);
    }

    #[test]
    fn an_explicit_null_controller_leaves_the_camera_undriven() {
        let args: Camera3DArgs = serde_json::from_str(r#"{"controller":null}"#).unwrap();
        assert!(args.controller.is_none());
    }

    #[test]
    fn a_ground_walker_turns_free_fly_off() {
        let args: Camera3DArgs =
            serde_json::from_str(r#"{"controller":{"free_fly":false,"player_radius":0.4}}"#)
                .unwrap();
        let c = args.controller.expect("controller");
        assert!(!c.free_fly);
        assert_eq!(c.player_radius, 0.4);
        // Fields the args did not mention keep the schema defaults.
        assert_eq!(c.mouse_sensitivity, 0.0015);
    }

    #[test]
    fn a_follow_controller_drives_from_root_motion_unless_told_otherwise() {
        crate::test_support::install_resolvers();
        let f = FollowController::default();
        assert_eq!(f.drive, FollowDrive::RootMotion);
        assert_eq!(f.speed_parameter, "speed");
        assert_eq!((f.distance, f.height), (4.0, 1.5));
        assert_eq!(f.jump_height, 0.0);

        let args: Camera3DArgs = serde_json::from_str(
            r#"{"controller":{"follow":{"target":"hero","drive":"direct","jump_height":1.2}}}"#,
        )
        .unwrap();
        let f = args
            .controller
            .expect("controller")
            .follow
            .expect("follow controller");
        assert_eq!(f.target, Some(SkinnedMeshHandle(4)));
        assert_eq!(f.drive, FollowDrive::Direct);
        assert_eq!(f.jump_height, 1.2);
    }

    #[test]
    fn drive_names_parse_in_snake_case() {
        let d = |s: &str| serde_json::from_str::<FollowDrive>(s).unwrap();
        assert_eq!(d(r#""root_motion""#), FollowDrive::RootMotion);
        assert_eq!(d(r#""direct""#), FollowDrive::Direct);
        assert_eq!(
            serde_json::to_string(&FollowDrive::RootMotion).unwrap(),
            r#""root_motion""#
        );
    }

    #[test]
    fn an_authored_camera_round_trips_through_postcard() {
        let args: Camera3DArgs = serde_json::from_str(
            r#"{"fov_y_degrees":60,"position":[1,2,3],"yaw":0.5,"pitch":-0.2,
                "controller":{"free_fly":false,"follow":{"distance":6.0}}}"#,
        )
        .unwrap();
        let bytes = postcard::to_allocvec(&args).unwrap();
        let back: Camera3DArgs = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(back.position, [1.0, 2.0, 3.0]);
        assert_eq!((back.yaw, back.pitch), (0.5, -0.2));
        let c = back.controller.expect("controller");
        assert!(!c.free_fly);
        assert_eq!(c.follow.expect("follow controller").distance, 6.0);
    }
}

/// Declares the 3D camera. One per scene.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Camera3D {
    /// Vertical field of view in degrees.
    pub fov_y_degrees: f32,
    /// Near clip distance in world units.
    pub near: f32,
    /// Far clip distance in world units.
    pub far: f32,
    /// Current view matrix, written each step by the active camera system.
    /// Column-major, matching the GLSL mat4 convention.
    pub view_matrix: [[f32; 4]; 4],
    /// Current world-space eye position, kept in sync with view_matrix.
    pub position: [f32; 3],
    /// Current yaw in radians.
    pub yaw: f32,
    /// Current pitch in radians.
    pub pitch: f32,
    /// World-space horizontal movement intent (units/second). Written by
    /// Camera3DSystem each frame, consumed by PhysicsSystem. Runtime-only.
    pub desired_move: [f32; 3],
    /// Set for one frame when the jump key is pressed. Runtime-only.
    pub jump_requested: bool,
    /// Set for one frame when the interact key is pressed. Runtime-only.
    pub interact_requested: bool,
    /// Controller settings, or `None` for an uncontrolled (cutscene) camera.
    /// Read once by the internal camera controller at init.
    pub controller: Option<CameraController>,
}

impl Camera3D {
    /// Translate the authored args into the runtime camera: compose the initial
    /// view matrix and zero the runtime state. Run by cook at build time (the
    /// baked blob record carries the result) and by tests that need a camera.
    pub fn bake(args: Camera3DArgs) -> Self {
        Self {
            fov_y_degrees: args.fov_y_degrees,
            near: args.near,
            far: args.far,
            view_matrix: crate::gfx::camera::view_matrix(args.position, args.yaw, args.pitch),
            position: args.position,
            yaw: args.yaw,
            pitch: args.pitch,
            desired_move: [0.0; 3],
            jump_requested: false,
            interact_requested: false,
            controller: args.controller,
        }
    }
}

impl Component for Camera3D {
    const NAME: &'static str = "Camera3D";

    fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
        Ok(crate::blob::decode_exact(bytes)?)
    }
}

#[cfg(test)]
mod runtime_tests {
    use super::*;
    use crate::components::FollowDrive;
    use crate::ecs::SkinnedMeshHandle;

    #[test]
    fn follow_block_deserializes_names_and_defaults() {
        crate::test_support::reset_interner();
        crate::test_support::intern_all(&["hero"]);
        let args: Camera3DArgs = serde_json::from_value(serde_json::json!({
            "controller": {"follow": {"target": "hero", "drive": "direct"}}
        }))
        .unwrap();
        let follow = args.controller.unwrap().follow.unwrap();
        // "hero" interns to id 0, and with no SkinnedMesh handle resolver installed
        // the reference falls back to that interned value as its handle.
        assert_eq!(follow.target, Some(SkinnedMeshHandle(0)));
        assert_eq!(follow.drive, FollowDrive::Direct);
        // Omitted fields keep the documented defaults.
        assert_eq!(follow.speed_parameter, "speed");
        assert!((follow.distance - 4.0).abs() < 1e-6);
        assert!((follow.height - 1.5).abs() < 1e-6);
        assert_eq!(follow.jump_height, 0.0);

        // No follow block keeps the first-person modes.
        let bare: Camera3DArgs =
            serde_json::from_value(serde_json::json!({"controller": {}})).unwrap();
        assert!(bare.controller.unwrap().follow.is_none());
    }
}