concinnity_core/components/camera3d.rs
1// src/components/camera3d.rs
2//
3// Runtime 3D camera component. Its authored args and controller config live in
4// this file, alongside the runtime component they bake into.
5
6use crate::ecs::Component;
7use crate::ecs::SkinnedMeshHandle;
8use crate::ecs::de_opt_skinned_mesh_handle;
9use alloc::string::{String, ToString};
10
11/// How a followed character converts movement input into displacement.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum FollowDrive {
15 /// The controller only writes the speed parameter and the facing; the
16 /// character moves by the displacement its animation clips carry (clips
17 /// baked with [root_motion](animation.md)). Clips must travel along
18 /// local -Z so the facing yaw and the travel direction agree.
19 RootMotion,
20 /// The controller moves the character capsule directly at the camera
21 /// controller's `move_speed`, for characters whose clips animate in
22 /// place. The speed parameter is still written, so a locomotion
23 /// blendspace matches the visual gait to the travel speed.
24 Direct,
25}
26
27/// Third-person follow settings carried on a [CameraController](#cameracontroller).
28///
29/// When `follow` is set the camera becomes a third-person orbit camera: the
30/// mouse orbits around the followed character, and WASD steers the character
31/// itself (camera-relative). The character must be a
32/// [SkinnedMesh](skinned_mesh.md) with a `capsule`, so it has a kinematic
33/// character capsule to move.
34#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
35#[serde(default)]
36pub struct FollowController {
37 /// Name of the followed [SkinnedMesh](skinned_mesh.md). It must declare a
38 /// `capsule`.
39 #[serde(deserialize_with = "de_opt_skinned_mesh_handle")]
40 pub target: Option<SkinnedMeshHandle>,
41 /// Orbit distance from the pivot to the camera, in world units.
42 pub distance: f32,
43 /// Pivot height above the character's feet, in world units.
44 pub height: f32,
45 /// How the character moves; see [FollowDrive](#followdrive).
46 pub drive: FollowDrive,
47 /// Character turn rate toward the input heading, in radians per second.
48 pub turn_speed: f32,
49 /// Name of the character's [AnimationGraph](anim_graph.md) float parameter
50 /// that receives the current travel speed in world units per second
51 /// (drives a locomotion blendspace). Empty disables parameter writes,
52 /// leaving the graph externally driven.
53 pub speed_parameter: String,
54 /// Jump apex height in world units when the jump key is pressed while
55 /// grounded. `0` disables jumping.
56 pub jump_height: f32,
57}
58
59impl Default for FollowController {
60 fn default() -> Self {
61 Self {
62 target: None,
63 distance: 4.0,
64 height: 1.5,
65 drive: FollowDrive::RootMotion,
66 turn_speed: 10.0,
67 speed_parameter: "speed".to_string(),
68 jump_height: 0.0,
69 }
70 }
71}
72
73/// First-person / fly-through controller settings carried on a `Camera3D`.
74///
75/// A `Camera3D` whose `controller` is set (the default) is driven each frame by
76/// the internal camera controller, which turns mouse/keyboard input into a
77/// camera orientation and a movement intent. Set `controller` to `null` for a
78/// camera driven by something else (a `CameraShot` / `Scene` cutscene).
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80#[serde(default)]
81pub struct CameraController {
82 /// Direct 6-DoF flight mode. WASD moves along the camera's full forward
83 /// vector (yaw + pitch) and jump rises along world +Y; the controller
84 /// writes the new position straight onto Camera3D, bypassing the physics
85 /// step and the bounds box. Used for inspector / fly-through cameras (the
86 /// default, e.g. the `cn add foo.glb` scaffold). Set `false` for the
87 /// FPS-style ground walker.
88 pub free_fly: bool,
89 /// Walk / fly speed in world units per second.
90 pub move_speed: f32,
91 /// Sprint multiplier applied when the sprint key is held.
92 pub sprint_multiplier: f32,
93 /// Mouse look sensitivity in radians per pixel.
94 pub mouse_sensitivity: f32,
95 /// Margin kept between the camera and the bounds box (world units).
96 pub player_radius: f32,
97 /// AABB minimum corner the camera centre must stay inside [x, y, z].
98 pub bounds_min: [f32; 3],
99 /// AABB maximum corner the camera centre must stay inside [x, y, z].
100 pub bounds_max: [f32; 3],
101 /// Third-person follow settings; see [FollowController](#followcontroller).
102 /// When set, the camera orbits the followed character and WASD steers the
103 /// character instead of the camera (`free_fly` and the bounds box are
104 /// ignored). `null` (the default) keeps the first-person / fly modes.
105 pub follow: Option<FollowController>,
106}
107
108impl Default for CameraController {
109 fn default() -> Self {
110 const BIG: f32 = 1.0e9;
111 Self {
112 // A bare `Camera3D` is navigable out of the box as a free-fly
113 // inspector: the `cn add foo.glb` scaffold relies on this. Worlds
114 // that want the FPS ground walker set `free_fly: false`.
115 free_fly: true,
116 move_speed: 1.0,
117 sprint_multiplier: 3.0,
118 mouse_sensitivity: 0.0015,
119 player_radius: 0.3,
120 bounds_min: [-BIG, -BIG, -BIG],
121 bounds_max: [BIG, BIG, BIG],
122 follow: None,
123 }
124 }
125}
126
127// A `Camera3D` with no explicit `controller` gets the default inspector
128// controller, so an authored scene is navigable out of the box.
129fn default_controller() -> Option<CameraController> {
130 Some(CameraController::default())
131}
132
133/// Authored fields of a `Camera3D`; the runtime view matrix and per-frame input
134/// intent are not declared.
135///
136/// ```rust
137/// # use concinnity_core::components::cook::Camera3D as Camera3DArgs;
138/// Camera3DArgs {
139/// fov_y_degrees: 80.0,
140/// near: 0.05,
141/// far: 500.0,
142/// position: [0.0, 4.0, 0.0],
143/// ..Default::default()
144/// };
145/// ```
146#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
147#[serde(default)]
148pub struct Camera3DArgs {
149 /// Vertical field-of-view in degrees.
150 pub fov_y_degrees: f32,
151 /// Near clip plane distance.
152 pub near: f32,
153 /// Far clip plane distance.
154 pub far: f32,
155 /// Initial eye position in world space [x, y, z].
156 pub position: [f32; 3],
157 /// Initial yaw in radians (0 = looking toward -Z).
158 pub yaw: f32,
159 /// Initial pitch in radians.
160 pub pitch: f32,
161 /// Input controller settings, or `null` to leave the camera uncontrolled
162 /// (driven by a [CameraShot](#camerashot) / [Scene](#scene)
163 /// cutscene). Omitted defaults to a free-fly inspector controller.
164 #[serde(default = "default_controller")]
165 pub controller: Option<CameraController>,
166}
167
168impl Default for Camera3DArgs {
169 fn default() -> Self {
170 Self {
171 fov_y_degrees: 75.0,
172 near: 0.05,
173 far: 200.0,
174 position: [0.0, 1.7, 0.0],
175 yaw: 0.0,
176 pitch: 0.0,
177 controller: default_controller(),
178 }
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn a_bare_camera_is_navigable_as_a_free_fly_inspector() {
188 // The `cn add foo.glb` scaffold declares only a Camera3D, so the default
189 // controller has to be the one that can fly around and look at it.
190 let args = Camera3DArgs::default();
191 let c = args.controller.expect("default inspector controller");
192 assert!(c.free_fly);
193 assert_eq!(c.move_speed, 1.0);
194 assert_eq!(c.sprint_multiplier, 3.0);
195 assert!(c.follow.is_none());
196 assert_eq!(args.position, [0.0, 1.7, 0.0]);
197 assert_eq!((args.near, args.far), (0.05, 200.0));
198 }
199
200 #[test]
201 fn default_bounds_do_not_constrain_the_camera() {
202 let c = CameraController::default();
203 assert!(c.bounds_min.iter().all(|&v| v <= -1.0e9));
204 assert!(c.bounds_max.iter().all(|&v| v >= 1.0e9));
205 }
206
207 #[test]
208 fn an_omitted_controller_still_gets_the_inspector() {
209 // `#[serde(default)]` on the struct would make an absent field `None`,
210 // so the field carries its own default fn.
211 let args: Camera3DArgs = serde_json::from_str(r#"{"fov_y_degrees":60}"#).unwrap();
212 assert_eq!(args.fov_y_degrees, 60.0);
213 assert!(args.controller.expect("inspector controller").free_fly);
214 }
215
216 #[test]
217 fn an_explicit_null_controller_leaves_the_camera_undriven() {
218 let args: Camera3DArgs = serde_json::from_str(r#"{"controller":null}"#).unwrap();
219 assert!(args.controller.is_none());
220 }
221
222 #[test]
223 fn a_ground_walker_turns_free_fly_off() {
224 let args: Camera3DArgs =
225 serde_json::from_str(r#"{"controller":{"free_fly":false,"player_radius":0.4}}"#)
226 .unwrap();
227 let c = args.controller.expect("controller");
228 assert!(!c.free_fly);
229 assert_eq!(c.player_radius, 0.4);
230 // Fields the args did not mention keep the schema defaults.
231 assert_eq!(c.mouse_sensitivity, 0.0015);
232 }
233
234 #[test]
235 fn a_follow_controller_drives_from_root_motion_unless_told_otherwise() {
236 crate::test_support::install_resolvers();
237 let f = FollowController::default();
238 assert_eq!(f.drive, FollowDrive::RootMotion);
239 assert_eq!(f.speed_parameter, "speed");
240 assert_eq!((f.distance, f.height), (4.0, 1.5));
241 assert_eq!(f.jump_height, 0.0);
242
243 let args: Camera3DArgs = serde_json::from_str(
244 r#"{"controller":{"follow":{"target":"hero","drive":"direct","jump_height":1.2}}}"#,
245 )
246 .unwrap();
247 let f = args
248 .controller
249 .expect("controller")
250 .follow
251 .expect("follow controller");
252 assert_eq!(f.target, Some(SkinnedMeshHandle(4)));
253 assert_eq!(f.drive, FollowDrive::Direct);
254 assert_eq!(f.jump_height, 1.2);
255 }
256
257 #[test]
258 fn drive_names_parse_in_snake_case() {
259 let d = |s: &str| serde_json::from_str::<FollowDrive>(s).unwrap();
260 assert_eq!(d(r#""root_motion""#), FollowDrive::RootMotion);
261 assert_eq!(d(r#""direct""#), FollowDrive::Direct);
262 assert_eq!(
263 serde_json::to_string(&FollowDrive::RootMotion).unwrap(),
264 r#""root_motion""#
265 );
266 }
267
268 #[test]
269 fn an_authored_camera_round_trips_through_postcard() {
270 let args: Camera3DArgs = serde_json::from_str(
271 r#"{"fov_y_degrees":60,"position":[1,2,3],"yaw":0.5,"pitch":-0.2,
272 "controller":{"free_fly":false,"follow":{"distance":6.0}}}"#,
273 )
274 .unwrap();
275 let bytes = postcard::to_allocvec(&args).unwrap();
276 let back: Camera3DArgs = postcard::from_bytes(&bytes).unwrap();
277 assert_eq!(back.position, [1.0, 2.0, 3.0]);
278 assert_eq!((back.yaw, back.pitch), (0.5, -0.2));
279 let c = back.controller.expect("controller");
280 assert!(!c.free_fly);
281 assert_eq!(c.follow.expect("follow controller").distance, 6.0);
282 }
283}
284
285/// Declares the 3D camera. One per scene.
286#[derive(Debug, serde::Serialize, serde::Deserialize)]
287pub struct Camera3D {
288 /// Vertical field of view in degrees.
289 pub fov_y_degrees: f32,
290 /// Near clip distance in world units.
291 pub near: f32,
292 /// Far clip distance in world units.
293 pub far: f32,
294 /// Current view matrix, written each step by the active camera system.
295 /// Column-major, matching the GLSL mat4 convention.
296 pub view_matrix: [[f32; 4]; 4],
297 /// Current world-space eye position, kept in sync with view_matrix.
298 pub position: [f32; 3],
299 /// Current yaw in radians.
300 pub yaw: f32,
301 /// Current pitch in radians.
302 pub pitch: f32,
303 /// World-space horizontal movement intent (units/second). Written by
304 /// Camera3DSystem each frame, consumed by PhysicsSystem. Runtime-only.
305 pub desired_move: [f32; 3],
306 /// Set for one frame when the jump key is pressed. Runtime-only.
307 pub jump_requested: bool,
308 /// Set for one frame when the interact key is pressed. Runtime-only.
309 pub interact_requested: bool,
310 /// Controller settings, or `None` for an uncontrolled (cutscene) camera.
311 /// Read once by the internal camera controller at init.
312 pub controller: Option<CameraController>,
313}
314
315impl Camera3D {
316 /// Translate the authored args into the runtime camera: compose the initial
317 /// view matrix and zero the runtime state. Run by cook at build time (the
318 /// baked blob record carries the result) and by tests that need a camera.
319 pub fn bake(args: Camera3DArgs) -> Self {
320 Self {
321 fov_y_degrees: args.fov_y_degrees,
322 near: args.near,
323 far: args.far,
324 view_matrix: crate::gfx::camera::view_matrix(args.position, args.yaw, args.pitch),
325 position: args.position,
326 yaw: args.yaw,
327 pitch: args.pitch,
328 desired_move: [0.0; 3],
329 jump_requested: false,
330 interact_requested: false,
331 controller: args.controller,
332 }
333 }
334}
335
336impl Component for Camera3D {
337 const NAME: &'static str = "Camera3D";
338
339 fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
340 Ok(crate::blob::decode_exact(bytes)?)
341 }
342}
343
344#[cfg(test)]
345mod runtime_tests {
346 use super::*;
347 use crate::components::FollowDrive;
348 use crate::ecs::SkinnedMeshHandle;
349
350 #[test]
351 fn follow_block_deserializes_names_and_defaults() {
352 crate::test_support::reset_interner();
353 crate::test_support::intern_all(&["hero"]);
354 let args: Camera3DArgs = serde_json::from_value(serde_json::json!({
355 "controller": {"follow": {"target": "hero", "drive": "direct"}}
356 }))
357 .unwrap();
358 let follow = args.controller.unwrap().follow.unwrap();
359 // "hero" interns to id 0, and with no SkinnedMesh handle resolver installed
360 // the reference falls back to that interned value as its handle.
361 assert_eq!(follow.target, Some(SkinnedMeshHandle(0)));
362 assert_eq!(follow.drive, FollowDrive::Direct);
363 // Omitted fields keep the documented defaults.
364 assert_eq!(follow.speed_parameter, "speed");
365 assert!((follow.distance - 4.0).abs() < 1e-6);
366 assert!((follow.height - 1.5).abs() < 1e-6);
367 assert_eq!(follow.jump_height, 0.0);
368
369 // No follow block keeps the first-person modes.
370 let bare: Camera3DArgs =
371 serde_json::from_value(serde_json::json!({"controller": {}})).unwrap();
372 assert!(bare.controller.unwrap().follow.is_none());
373 }
374}