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
// Camera authoring schema: the 3D camera's authored args and controller config.
// The runtime `Camera3D` component (view matrix, per-frame intent) lives in core.
use crate::{SkinnedMeshHandle, 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_asset::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);
}
}