Skip to main content

concinnity_asset/
camera3d.rs

1// Camera authoring schema: the 3D camera's authored args and controller config.
2// The runtime `Camera3D` component (view matrix, per-frame intent) lives in core.
3
4use crate::{SkinnedMeshHandle, de_opt_skinned_mesh_handle};
5use alloc::string::{String, ToString};
6
7/// How a followed character converts movement input into displacement.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum FollowDrive {
11    /// The controller only writes the speed parameter and the facing; the
12    /// character moves by the displacement its animation clips carry (clips
13    /// baked with [root_motion](animation.md)). Clips must travel along
14    /// local -Z so the facing yaw and the travel direction agree.
15    RootMotion,
16    /// The controller moves the character capsule directly at the camera
17    /// controller's `move_speed`, for characters whose clips animate in
18    /// place. The speed parameter is still written, so a locomotion
19    /// blendspace matches the visual gait to the travel speed.
20    Direct,
21}
22
23/// Third-person follow settings carried on a [CameraController](#cameracontroller).
24///
25/// When `follow` is set the camera becomes a third-person orbit camera: the
26/// mouse orbits around the followed character, and WASD steers the character
27/// itself (camera-relative). The character must be a
28/// [SkinnedMesh](skinned_mesh.md) with a `capsule`, so it has a kinematic
29/// character capsule to move.
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31#[serde(default)]
32pub struct FollowController {
33    /// Name of the followed [SkinnedMesh](skinned_mesh.md). It must declare a
34    /// `capsule`.
35    #[serde(deserialize_with = "de_opt_skinned_mesh_handle")]
36    pub target: Option<SkinnedMeshHandle>,
37    /// Orbit distance from the pivot to the camera, in world units.
38    pub distance: f32,
39    /// Pivot height above the character's feet, in world units.
40    pub height: f32,
41    /// How the character moves; see [FollowDrive](#followdrive).
42    pub drive: FollowDrive,
43    /// Character turn rate toward the input heading, in radians per second.
44    pub turn_speed: f32,
45    /// Name of the character's [AnimationGraph](anim_graph.md) float parameter
46    /// that receives the current travel speed in world units per second
47    /// (drives a locomotion blendspace). Empty disables parameter writes,
48    /// leaving the graph externally driven.
49    pub speed_parameter: String,
50    /// Jump apex height in world units when the jump key is pressed while
51    /// grounded. `0` disables jumping.
52    pub jump_height: f32,
53}
54
55impl Default for FollowController {
56    fn default() -> Self {
57        Self {
58            target: None,
59            distance: 4.0,
60            height: 1.5,
61            drive: FollowDrive::RootMotion,
62            turn_speed: 10.0,
63            speed_parameter: "speed".to_string(),
64            jump_height: 0.0,
65        }
66    }
67}
68
69/// First-person / fly-through controller settings carried on a `Camera3D`.
70///
71/// A `Camera3D` whose `controller` is set (the default) is driven each frame by
72/// the internal camera controller, which turns mouse/keyboard input into a
73/// camera orientation and a movement intent. Set `controller` to `null` for a
74/// camera driven by something else (a `CameraShot` / `Scene` cutscene).
75#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
76#[serde(default)]
77pub struct CameraController {
78    /// Direct 6-DoF flight mode. WASD moves along the camera's full forward
79    /// vector (yaw + pitch) and jump rises along world +Y; the controller
80    /// writes the new position straight onto Camera3D, bypassing the physics
81    /// step and the bounds box. Used for inspector / fly-through cameras (the
82    /// default, e.g. the `cn add foo.glb` scaffold). Set `false` for the
83    /// FPS-style ground walker.
84    pub free_fly: bool,
85    /// Walk / fly speed in world units per second.
86    pub move_speed: f32,
87    /// Sprint multiplier applied when the sprint key is held.
88    pub sprint_multiplier: f32,
89    /// Mouse look sensitivity in radians per pixel.
90    pub mouse_sensitivity: f32,
91    /// Margin kept between the camera and the bounds box (world units).
92    pub player_radius: f32,
93    /// AABB minimum corner the camera centre must stay inside [x, y, z].
94    pub bounds_min: [f32; 3],
95    /// AABB maximum corner the camera centre must stay inside [x, y, z].
96    pub bounds_max: [f32; 3],
97    /// Third-person follow settings; see [FollowController](#followcontroller).
98    /// When set, the camera orbits the followed character and WASD steers the
99    /// character instead of the camera (`free_fly` and the bounds box are
100    /// ignored). `null` (the default) keeps the first-person / fly modes.
101    pub follow: Option<FollowController>,
102}
103
104impl Default for CameraController {
105    fn default() -> Self {
106        const BIG: f32 = 1.0e9;
107        Self {
108            // A bare `Camera3D` is navigable out of the box as a free-fly
109            // inspector: the `cn add foo.glb` scaffold relies on this. Worlds
110            // that want the FPS ground walker set `free_fly: false`.
111            free_fly: true,
112            move_speed: 1.0,
113            sprint_multiplier: 3.0,
114            mouse_sensitivity: 0.0015,
115            player_radius: 0.3,
116            bounds_min: [-BIG, -BIG, -BIG],
117            bounds_max: [BIG, BIG, BIG],
118            follow: None,
119        }
120    }
121}
122
123// A `Camera3D` with no explicit `controller` gets the default inspector
124// controller, so an authored scene is navigable out of the box.
125fn default_controller() -> Option<CameraController> {
126    Some(CameraController::default())
127}
128
129/// Authored fields of a `Camera3D`; the runtime view matrix and per-frame input
130/// intent are not declared.
131///
132/// ```rust
133/// # use concinnity_asset::cook::Camera3D as Camera3DArgs;
134/// Camera3DArgs {
135///     fov_y_degrees: 80.0,
136///     near: 0.05,
137///     far: 500.0,
138///     position: [0.0, 4.0, 0.0],
139///     ..Default::default()
140/// };
141/// ```
142#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
143#[serde(default)]
144pub struct Camera3DArgs {
145    /// Vertical field-of-view in degrees.
146    pub fov_y_degrees: f32,
147    /// Near clip plane distance.
148    pub near: f32,
149    /// Far clip plane distance.
150    pub far: f32,
151    /// Initial eye position in world space [x, y, z].
152    pub position: [f32; 3],
153    /// Initial yaw in radians (0 = looking toward -Z).
154    pub yaw: f32,
155    /// Initial pitch in radians.
156    pub pitch: f32,
157    /// Input controller settings, or `null` to leave the camera uncontrolled
158    /// (driven by a [CameraShot](#camerashot) / [Scene](#scene)
159    /// cutscene). Omitted defaults to a free-fly inspector controller.
160    #[serde(default = "default_controller")]
161    pub controller: Option<CameraController>,
162}
163
164impl Default for Camera3DArgs {
165    fn default() -> Self {
166        Self {
167            fov_y_degrees: 75.0,
168            near: 0.05,
169            far: 200.0,
170            position: [0.0, 1.7, 0.0],
171            yaw: 0.0,
172            pitch: 0.0,
173            controller: default_controller(),
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn a_bare_camera_is_navigable_as_a_free_fly_inspector() {
184        // The `cn add foo.glb` scaffold declares only a Camera3D, so the default
185        // controller has to be the one that can fly around and look at it.
186        let args = Camera3DArgs::default();
187        let c = args.controller.expect("default inspector controller");
188        assert!(c.free_fly);
189        assert_eq!(c.move_speed, 1.0);
190        assert_eq!(c.sprint_multiplier, 3.0);
191        assert!(c.follow.is_none());
192        assert_eq!(args.position, [0.0, 1.7, 0.0]);
193        assert_eq!((args.near, args.far), (0.05, 200.0));
194    }
195
196    #[test]
197    fn default_bounds_do_not_constrain_the_camera() {
198        let c = CameraController::default();
199        assert!(c.bounds_min.iter().all(|&v| v <= -1.0e9));
200        assert!(c.bounds_max.iter().all(|&v| v >= 1.0e9));
201    }
202
203    #[test]
204    fn an_omitted_controller_still_gets_the_inspector() {
205        // `#[serde(default)]` on the struct would make an absent field `None`,
206        // so the field carries its own default fn.
207        let args: Camera3DArgs = serde_json::from_str(r#"{"fov_y_degrees":60}"#).unwrap();
208        assert_eq!(args.fov_y_degrees, 60.0);
209        assert!(args.controller.expect("inspector controller").free_fly);
210    }
211
212    #[test]
213    fn an_explicit_null_controller_leaves_the_camera_undriven() {
214        let args: Camera3DArgs = serde_json::from_str(r#"{"controller":null}"#).unwrap();
215        assert!(args.controller.is_none());
216    }
217
218    #[test]
219    fn a_ground_walker_turns_free_fly_off() {
220        let args: Camera3DArgs =
221            serde_json::from_str(r#"{"controller":{"free_fly":false,"player_radius":0.4}}"#)
222                .unwrap();
223        let c = args.controller.expect("controller");
224        assert!(!c.free_fly);
225        assert_eq!(c.player_radius, 0.4);
226        // Fields the args did not mention keep the schema defaults.
227        assert_eq!(c.mouse_sensitivity, 0.0015);
228    }
229
230    #[test]
231    fn a_follow_controller_drives_from_root_motion_unless_told_otherwise() {
232        crate::test_support::install_resolvers();
233        let f = FollowController::default();
234        assert_eq!(f.drive, FollowDrive::RootMotion);
235        assert_eq!(f.speed_parameter, "speed");
236        assert_eq!((f.distance, f.height), (4.0, 1.5));
237        assert_eq!(f.jump_height, 0.0);
238
239        let args: Camera3DArgs = serde_json::from_str(
240            r#"{"controller":{"follow":{"target":"hero","drive":"direct","jump_height":1.2}}}"#,
241        )
242        .unwrap();
243        let f = args
244            .controller
245            .expect("controller")
246            .follow
247            .expect("follow controller");
248        assert_eq!(f.target, Some(SkinnedMeshHandle(4)));
249        assert_eq!(f.drive, FollowDrive::Direct);
250        assert_eq!(f.jump_height, 1.2);
251    }
252
253    #[test]
254    fn drive_names_parse_in_snake_case() {
255        let d = |s: &str| serde_json::from_str::<FollowDrive>(s).unwrap();
256        assert_eq!(d(r#""root_motion""#), FollowDrive::RootMotion);
257        assert_eq!(d(r#""direct""#), FollowDrive::Direct);
258        assert_eq!(
259            serde_json::to_string(&FollowDrive::RootMotion).unwrap(),
260            r#""root_motion""#
261        );
262    }
263
264    #[test]
265    fn an_authored_camera_round_trips_through_postcard() {
266        let args: Camera3DArgs = serde_json::from_str(
267            r#"{"fov_y_degrees":60,"position":[1,2,3],"yaw":0.5,"pitch":-0.2,
268                "controller":{"free_fly":false,"follow":{"distance":6.0}}}"#,
269        )
270        .unwrap();
271        let bytes = postcard::to_allocvec(&args).unwrap();
272        let back: Camera3DArgs = postcard::from_bytes(&bytes).unwrap();
273        assert_eq!(back.position, [1.0, 2.0, 3.0]);
274        assert_eq!((back.yaw, back.pitch), (0.5, -0.2));
275        let c = back.controller.expect("controller");
276        assert!(!c.free_fly);
277        assert_eq!(c.follow.expect("follow controller").distance, 6.0);
278    }
279}