concinnity_asset/physics_config.rs
1// World-level physics configuration schema.
2
3use crate::{AssetId, de_opt_asset_ref};
4use alloc::string::String;
5use alloc::vec::Vec;
6
7/// Configures the world's physics floor / terrain.
8///
9/// Optional: a world with physics bodies but no `PhysicsConfig` simulates over a
10/// flat floor at Y = 0, and the build injects one carrying these values so the
11/// settings are visible in `world-lock.json`. Physics runs whenever the world
12/// declares a `PhysicsConfig`, a [RigidBody](#rigidbody), a
13/// [PropBody](#propbody), a [TriggerVolume](#triggervolume), or a
14/// [SkinnedMesh](#skinnedmesh) with a `capsule`. Declare a `PhysicsConfig` to
15/// put bodies on terrain or a non-zero floor.
16///
17/// For terrain-based outdoor scenes the terrain parameters must match the
18/// terrain mesh exactly.
19///
20/// ```rust
21/// # use concinnity_asset::PhysicsConfig;
22/// PhysicsConfig {
23/// terrain_offset_y: -0.5,
24/// ..Default::default()
25/// };
26/// ```
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28#[serde(default)]
29pub struct PhysicsConfig {
30 /// Y coordinate of the floor. When left at 0.0 it is auto-detected from the
31 /// camera; set it explicitly to override.
32 pub floor_y: f32,
33 /// Half-width of the terrain mesh along X. Must match the terrain mesh.
34 /// Leave at 0.0 (with `terrain_subdivisions` = 0) for flat-floor scenes.
35 pub terrain_half_width: f32,
36 /// Half-depth of the terrain mesh along Z. Must match the terrain mesh.
37 pub terrain_half_depth: f32,
38 /// Subdivision count of the terrain mesh. When 0, a flat floor at Y = 0 is
39 /// used instead of a heightfield.
40 pub terrain_subdivisions: u32,
41 /// Height variation of the terrain mesh. Must match the terrain mesh.
42 pub terrain_amplitude: f32,
43 /// World-space Y offset of the terrain: the height of the prop that renders
44 /// the terrain mesh. Leave at 0.0 when the terrain sits at the origin.
45 pub terrain_offset_y: f32,
46 /// Name of a [ProceduralMesh](#proceduralmesh) with `generator:
47 /// "heightfield"`. When set, the physics surface is built from that mesh's
48 /// source image so props rest on the visible terrain. Takes precedence over
49 /// the `terrain_*` values above.
50 #[serde(default, deserialize_with = "de_opt_asset_ref")]
51 pub terrain_mesh: Option<AssetId>,
52 /// Extra collision layer names beyond the built-ins (`world`, `prop`,
53 /// `character`, `trigger`). At most 28; referenced by collider `layer`
54 /// fields and `no_collide` pairs.
55 pub layers: Vec<String>,
56 /// Unordered layer-name pairs that do not collide. Everything collides by
57 /// default; each pair here disables collision (and contact solving) between
58 /// its two layers symmetrically. Pairs naming `character` also filter the
59 /// character controller's movement.
60 pub no_collide: Vec<[String; 2]>,
61 /// Minimum contact impulse (mass times velocity change) for a collision to
62 /// publish a contact event. Resting contact stays below it; raise to hear
63 /// only hard impacts.
64 pub contact_min_impulse: f32,
65 /// Extra physics bodies reserved for props created while the world runs
66 /// (by a [Spawner](#spawner), a [Behavior](#behavior) `spawn` node, or the
67 /// host). Physics reserves every body it will ever need when the world
68 /// loads and never grows: once the declared bodies plus this many are
69 /// live, a further spawn gets no physics body and is reported as an error.
70 ///
71 /// This is a floor beneath what the build reserves on its own, not the
72 /// whole reservation. Every [Spawner](#spawner) whose `interval` and
73 /// `lifetime` bound how many copies can be alive at once is already
74 /// reserved for, and the larger of the two numbers wins. Set a value here
75 /// for the sources the build cannot count: a `Spawner` with `lifetime: 0`
76 /// (its copies live forever), a `spawn` node in a behavior, and spawns the
77 /// host drives itself.
78 pub spawn_headroom: u32,
79}
80
81impl Default for PhysicsConfig {
82 fn default() -> Self {
83 Self {
84 floor_y: 0.0,
85 terrain_half_width: 0.0,
86 terrain_half_depth: 0.0,
87 terrain_subdivisions: 0,
88 terrain_amplitude: 0.0,
89 terrain_offset_y: 0.0,
90 terrain_mesh: None,
91 layers: Vec::new(),
92 no_collide: Vec::new(),
93 contact_min_impulse: 1.0,
94 spawn_headroom: 0,
95 }
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use alloc::string::ToString;
103 use alloc::vec;
104
105 #[test]
106 fn a_blank_config_is_a_flat_floor_at_the_origin() {
107 let p = PhysicsConfig::default();
108 assert_eq!(p.floor_y, 0.0);
109 assert_eq!(p.terrain_amplitude, 0.0);
110 assert_eq!(p.terrain_subdivisions, 0);
111 assert_eq!(p.terrain_offset_y, 0.0);
112 // No mesh named means the generated terrain values are what is used.
113 assert!(p.terrain_mesh.is_none());
114 // Everything collides by default; light impacts stay silent.
115 assert!(p.layers.is_empty());
116 assert!(p.no_collide.is_empty());
117 assert_eq!(p.contact_min_impulse, 1.0);
118 // Nothing is held back for runtime spawns unless a world asks for it.
119 assert_eq!(p.spawn_headroom, 0);
120 }
121
122 #[test]
123 fn authored_spawn_headroom_round_trips_through_postcard() {
124 let p: PhysicsConfig = serde_json::from_str(r#"{"spawn_headroom":64}"#).unwrap();
125 assert_eq!(p.spawn_headroom, 64);
126
127 // The runtime reads the headroom off the baked component, so it has to
128 // survive the wire, not just the JSON parse.
129 let bytes = postcard::to_allocvec(&p).unwrap();
130 let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
131 assert_eq!(back.spawn_headroom, 64);
132 }
133
134 #[test]
135 fn layers_and_no_collide_parse_and_round_trip_through_postcard() {
136 let p: PhysicsConfig = serde_json::from_str(
137 r#"{"layers":["debris"],"no_collide":[["debris","character"]],
138 "contact_min_impulse":2.5}"#,
139 )
140 .unwrap();
141 assert_eq!(p.layers, vec!["debris".to_string()]);
142 assert_eq!(
143 p.no_collide,
144 vec![["debris".to_string(), "character".to_string()]]
145 );
146
147 let bytes = postcard::to_allocvec(&p).unwrap();
148 let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
149 assert_eq!(back.layers, p.layers);
150 assert_eq!(back.no_collide, p.no_collide);
151 assert_eq!(back.contact_min_impulse, 2.5);
152 }
153
154 #[test]
155 fn a_named_terrain_mesh_parses_and_round_trips_through_postcard() {
156 crate::test_support::install_resolvers();
157 let p: PhysicsConfig = serde_json::from_str(
158 r#"{"floor_y":-1.5,"terrain_half_width":128,"terrain_half_depth":128,
159 "terrain_subdivisions":64,"terrain_amplitude":12,"terrain_offset_y":2,
160 "terrain_mesh":"ground"}"#,
161 )
162 .unwrap();
163 assert_eq!(p.terrain_mesh, Some(AssetId(6)));
164
165 let bytes = postcard::to_allocvec(&p).unwrap();
166 let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
167 assert_eq!(back.floor_y, -1.5);
168 assert_eq!(back.terrain_half_width, 128.0);
169 assert_eq!(back.terrain_half_depth, 128.0);
170 assert_eq!(back.terrain_subdivisions, 64);
171 assert_eq!(back.terrain_amplitude, 12.0);
172 assert_eq!(back.terrain_offset_y, 2.0);
173 assert_eq!(back.terrain_mesh, Some(AssetId(6)));
174 }
175}