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}