Skip to main content

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}