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
// World-level physics configuration schema.
use crate::{AssetId, de_opt_asset_ref};
use alloc::string::String;
use alloc::vec::Vec;
/// Configures the world's physics floor / terrain.
///
/// Optional: a world with physics bodies but no `PhysicsConfig` simulates over a
/// flat floor at Y = 0, and the build injects one carrying these values so the
/// settings are visible in `world-lock.json`. Physics runs whenever the world
/// declares a `PhysicsConfig`, a [RigidBody](#rigidbody), a
/// [PropBody](#propbody), a [TriggerVolume](#triggervolume), or a
/// [SkinnedMesh](#skinnedmesh) with a `capsule`. Declare a `PhysicsConfig` to
/// put bodies on terrain or a non-zero floor.
///
/// For terrain-based outdoor scenes the terrain parameters must match the
/// terrain mesh exactly.
///
/// ```rust
/// # use concinnity_asset::PhysicsConfig;
/// PhysicsConfig {
/// terrain_offset_y: -0.5,
/// ..Default::default()
/// };
/// ```
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PhysicsConfig {
/// Y coordinate of the floor. When left at 0.0 it is auto-detected from the
/// camera; set it explicitly to override.
pub floor_y: f32,
/// Half-width of the terrain mesh along X. Must match the terrain mesh.
/// Leave at 0.0 (with `terrain_subdivisions` = 0) for flat-floor scenes.
pub terrain_half_width: f32,
/// Half-depth of the terrain mesh along Z. Must match the terrain mesh.
pub terrain_half_depth: f32,
/// Subdivision count of the terrain mesh. When 0, a flat floor at Y = 0 is
/// used instead of a heightfield.
pub terrain_subdivisions: u32,
/// Height variation of the terrain mesh. Must match the terrain mesh.
pub terrain_amplitude: f32,
/// World-space Y offset of the terrain: the height of the prop that renders
/// the terrain mesh. Leave at 0.0 when the terrain sits at the origin.
pub terrain_offset_y: f32,
/// Name of a [ProceduralMesh](#proceduralmesh) with `generator:
/// "heightfield"`. When set, the physics surface is built from that mesh's
/// source image so props rest on the visible terrain. Takes precedence over
/// the `terrain_*` values above.
#[serde(default, deserialize_with = "de_opt_asset_ref")]
pub terrain_mesh: Option<AssetId>,
/// Extra collision layer names beyond the built-ins (`world`, `prop`,
/// `character`, `trigger`). At most 28; referenced by collider `layer`
/// fields and `no_collide` pairs.
pub layers: Vec<String>,
/// Unordered layer-name pairs that do not collide. Everything collides by
/// default; each pair here disables collision (and contact solving) between
/// its two layers symmetrically. Pairs naming `character` also filter the
/// character controller's movement.
pub no_collide: Vec<[String; 2]>,
/// Minimum contact impulse (mass times velocity change) for a collision to
/// publish a contact event. Resting contact stays below it; raise to hear
/// only hard impacts.
pub contact_min_impulse: f32,
/// Extra physics bodies reserved for props created while the world runs
/// (by a [Spawner](#spawner), a [Behavior](#behavior) `spawn` node, or the
/// host). Physics reserves every body it will ever need when the world
/// loads and never grows: once the declared bodies plus this many are
/// live, a further spawn gets no physics body and is reported as an error.
///
/// This is a floor beneath what the build reserves on its own, not the
/// whole reservation. Every [Spawner](#spawner) whose `interval` and
/// `lifetime` bound how many copies can be alive at once is already
/// reserved for, and the larger of the two numbers wins. Set a value here
/// for the sources the build cannot count: a `Spawner` with `lifetime: 0`
/// (its copies live forever), a `spawn` node in a behavior, and spawns the
/// host drives itself.
pub spawn_headroom: u32,
}
impl Default for PhysicsConfig {
fn default() -> Self {
Self {
floor_y: 0.0,
terrain_half_width: 0.0,
terrain_half_depth: 0.0,
terrain_subdivisions: 0,
terrain_amplitude: 0.0,
terrain_offset_y: 0.0,
terrain_mesh: None,
layers: Vec::new(),
no_collide: Vec::new(),
contact_min_impulse: 1.0,
spawn_headroom: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::string::ToString;
use alloc::vec;
#[test]
fn a_blank_config_is_a_flat_floor_at_the_origin() {
let p = PhysicsConfig::default();
assert_eq!(p.floor_y, 0.0);
assert_eq!(p.terrain_amplitude, 0.0);
assert_eq!(p.terrain_subdivisions, 0);
assert_eq!(p.terrain_offset_y, 0.0);
// No mesh named means the generated terrain values are what is used.
assert!(p.terrain_mesh.is_none());
// Everything collides by default; light impacts stay silent.
assert!(p.layers.is_empty());
assert!(p.no_collide.is_empty());
assert_eq!(p.contact_min_impulse, 1.0);
// Nothing is held back for runtime spawns unless a world asks for it.
assert_eq!(p.spawn_headroom, 0);
}
#[test]
fn authored_spawn_headroom_round_trips_through_postcard() {
let p: PhysicsConfig = serde_json::from_str(r#"{"spawn_headroom":64}"#).unwrap();
assert_eq!(p.spawn_headroom, 64);
// The runtime reads the headroom off the baked component, so it has to
// survive the wire, not just the JSON parse.
let bytes = postcard::to_allocvec(&p).unwrap();
let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
assert_eq!(back.spawn_headroom, 64);
}
#[test]
fn layers_and_no_collide_parse_and_round_trip_through_postcard() {
let p: PhysicsConfig = serde_json::from_str(
r#"{"layers":["debris"],"no_collide":[["debris","character"]],
"contact_min_impulse":2.5}"#,
)
.unwrap();
assert_eq!(p.layers, vec!["debris".to_string()]);
assert_eq!(
p.no_collide,
vec![["debris".to_string(), "character".to_string()]]
);
let bytes = postcard::to_allocvec(&p).unwrap();
let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
assert_eq!(back.layers, p.layers);
assert_eq!(back.no_collide, p.no_collide);
assert_eq!(back.contact_min_impulse, 2.5);
}
#[test]
fn a_named_terrain_mesh_parses_and_round_trips_through_postcard() {
crate::test_support::install_resolvers();
let p: PhysicsConfig = serde_json::from_str(
r#"{"floor_y":-1.5,"terrain_half_width":128,"terrain_half_depth":128,
"terrain_subdivisions":64,"terrain_amplitude":12,"terrain_offset_y":2,
"terrain_mesh":"ground"}"#,
)
.unwrap();
assert_eq!(p.terrain_mesh, Some(AssetId(6)));
let bytes = postcard::to_allocvec(&p).unwrap();
let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
assert_eq!(back.floor_y, -1.5);
assert_eq!(back.terrain_half_width, 128.0);
assert_eq!(back.terrain_half_depth, 128.0);
assert_eq!(back.terrain_subdivisions, 64);
assert_eq!(back.terrain_amplitude, 12.0);
assert_eq!(back.terrain_offset_y, 2.0);
assert_eq!(back.terrain_mesh, Some(AssetId(6)));
}
}