1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
// 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());
}
}