Skip to main content

concinnity_asset/
skinned_mesh.rs

1// Skinned-mesh schema: skeletal geometry, its bind-pose joints, and an optional
2// character capsule.
3
4use crate::{
5    AssetId, MaterialHandle, PayloadLocator, TextureHandle, de_opt_material_handle,
6    de_opt_texture_handle,
7};
8use alloc::string::String;
9use alloc::vec::Vec;
10
11fn white() -> [f32; 3] {
12    [1.0, 1.0, 1.0]
13}
14
15fn first_weight() -> [f32; 4] {
16    [1.0, 0.0, 0.0, 0.0]
17}
18
19/// One vertex of a skinned mesh. Beyond position / colour / uv it carries up
20/// to four joint bindings: `joints[k]` indexes the skeleton, `weights[k]` is
21/// its blend weight. Weights are normalised at build time.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct SkinnedVertexData {
24    /// Vertex position `[x, y, z]` in model space.
25    pub pos: [f32; 3],
26    /// Vertex colour `[r, g, b]` in [0, 1]. Defaults to white.
27    #[serde(default = "white")]
28    pub color: [f32; 3],
29    /// Texture coordinates in [0, 1] space. Defaults to [0, 0].
30    #[serde(default)]
31    pub uv: [f32; 2],
32    /// Joint indices this vertex is bound to. Unused slots can be 0.
33    #[serde(default)]
34    pub joints: [u32; 4],
35    /// Blend weights parallel to `joints`. Defaults to fully bound to joint 0.
36    #[serde(default = "first_weight")]
37    pub weights: [f32; 4],
38}
39
40/// One morph-target vertex delta: offsets added to the bind-pose position and
41/// normal, scaled by the target's weight at runtime.
42#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
43#[serde(default)]
44pub struct MorphDelta {
45    /// Position offset `[x, y, z]` in model space.
46    pub position: [f32; 3],
47    /// Normal offset; the deformed normal is re-normalised after adding it.
48    pub normal: [f32; 3],
49}
50
51/// One joint of a skeleton's bind pose.
52#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
53#[serde(default)]
54pub struct SkeletonJoint {
55    /// Human-readable joint name (animation tracks may reference it later).
56    pub name: String,
57    /// Parent joint index, or -1 for a root. Parents must appear before their
58    /// children in the `skeleton` list.
59    pub parent: i32,
60    /// Local bind translation relative to the parent.
61    pub translation: [f32; 3],
62    /// Local bind rotation, Euler degrees [pitch, yaw, roll], YXZ order.
63    pub rotation_deg: [f32; 3],
64    /// Local bind scale.
65    pub scale: [f32; 3],
66}
67
68impl Default for SkeletonJoint {
69    fn default() -> Self {
70        Self {
71            name: String::new(),
72            parent: -1,
73            translation: [0.0, 0.0, 0.0],
74            rotation_deg: [0.0, 0.0, 0.0],
75            scale: [1.0, 1.0, 1.0],
76        }
77    }
78}
79
80/// A skeletally animated mesh placed directly in the world.
81///
82/// Unlike a [Mesh](#mesh), a `SkinnedMesh` carries its own world transform and a
83/// `skeleton` (a joint hierarchy with a bind pose). Each vertex is bound to up
84/// to four joints; an [Animation](#animation) targeting this mesh deforms it at
85/// runtime. With no animation the mesh renders in its bind pose.
86///
87/// The geometry + skeleton may be authored inline (`vertices` / `indices` /
88/// `skeleton`) or imported with `source` from a glTF (`.glb` / `.gltf`) or
89/// binary `.fbx` file. The import fills the mesh, the skeleton bind pose,
90/// and (for glTF) any morph targets; animations are imported separately by
91/// [Animation](#animation) assets referencing the same file.
92///
93/// The `customize_character` example ships a neutral unclothed body
94/// (`base_humanoid.glb`, about 19k vertices, A-pose bind) with a 25-joint
95/// skeleton (`root`, `hips`, `spine`, `chest`,
96/// `upper_chest`, `neck`, `head`, and `clavicle` / `upper_arm` / `forearm` /
97/// `hand` / `thumb` / `thigh` / `shin` / `foot` / `toe` with an `_l` / `_r`
98/// suffix), the morph targets a [CharacterShape](#charactershape) slider set
99/// names (`weight+/-`, `muscle`, `shoulders+/-`, `hips+/-`, `chest+/-`,
100/// `belly`, `head+/-`, `jaw+/-`, `nose+/-`, `brow`, `cheeks+/-`,
101/// `chin+/-`), and a rotation-only `idle` clip an Animation can import from
102/// the same file; a [CharacterModel](#charactermodel) is the usual way to
103/// declare it.
104///
105/// The `skeleton` (joint hierarchy and bind pose) is provided as an arg
106/// (authored inline alongside `vertices`/`indices`, or filled in from the
107/// imported `.glb`) and is baked into the mesh at build time.
108///
109/// Normals and tangents are computed automatically at build time. Do not
110/// supply them.
111///
112/// ```rust
113/// # use concinnity_asset::SkinnedMesh;
114/// SkinnedMesh {
115///     position: [0.0, 1.0, 0.0],
116///     ..Default::default()
117/// };
118/// ```
119#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
120#[serde(default)]
121pub struct SkinnedMesh {
122    /// Asset identity; injected via `inject_name`. Not part of `args`.
123    #[serde(skip)]
124    pub asset_id: AssetId,
125    /// Optional path to a `.glb` / `.gltf` / `.fbx` file. When set, the
126    /// build imports `vertices` / `indices` / `skeleton` from it; an
127    /// inline-authored mesh leaves this empty.
128    pub source: String,
129    /// Which skinned mesh of `source` to import, in file declaration order
130    /// (default 0). A character split into several meshes bound to one
131    /// skeleton (body, hair, clothes) needs one `SkinnedMesh` per part, each
132    /// naming its own index.
133    pub skin_index: u32,
134    /// Skinned vertex list.
135    pub vertices: Vec<SkinnedVertexData>,
136    /// Triangle index list.
137    pub indices: Vec<u16>,
138    /// Morph-target names, one per target, in target order. Filled from the
139    /// source file's target names when importing; empty for a mesh without
140    /// morph targets.
141    pub morph_target_names: Vec<String>,
142    /// Dense morph-target deltas, target-major: entry `t * vertex_count + v`
143    /// is target `t`'s delta for vertex `v`. Length must be
144    /// `morph_target_names.len() * vertices.len()`. An [Animation](#animation)
145    /// with a `morph_track` drives the per-target weights at runtime.
146    pub morph_deltas: Vec<MorphDelta>,
147    /// [Material](#material); provides the albedo texture plus lighting
148    /// parameters.
149    #[serde(deserialize_with = "de_opt_material_handle")]
150    pub material: Option<MaterialHandle>,
151    /// [Texture](#texture) (older path); ignored when `material` is set.
152    #[serde(deserialize_with = "de_opt_texture_handle")]
153    pub texture: Option<TextureHandle>,
154    /// World-space position.
155    pub position: [f32; 3],
156    /// World rotation, Euler degrees [pitch, yaw, roll], YXZ order.
157    pub rotation_deg: [f32; 3],
158    /// World scale.
159    pub scale: [f32; 3],
160    /// Number of level-of-detail versions to generate, including the original.
161    /// `1` (the default) generates none; values are clamped to `[1, 8]`.
162    pub lod_levels: u32,
163    /// Camera distances at which to switch to each lower-detail version. When
164    /// non-empty, must have exactly `lod_levels - 1` entries; empty lets the
165    /// build choose defaults.
166    #[serde(default)]
167    pub lod_distances: Vec<f32>,
168    /// How many runtime copies of this mesh may exist at once beyond the
169    /// authored one. `0` (the default) means the mesh is not runtime-spawnable.
170    /// A non-zero value pre-reserves that many extra instance slots at load: the
171    /// engine appends that many hidden bind-pose copies to the skinned geometry
172    /// so a runtime spawn can claim one without growing any GPU buffer, and a
173    /// despawn returns it to the pool. Spawns past the reserve are dropped (a
174    /// warning is logged). Capped at 4096.
175    pub max_instances: u32,
176    /// Optional character capsule. When set, the mesh collides with the
177    /// scene as a kinematic character and is moved by the root motion of its
178    /// [Animation](#animation) clips (those with `root_motion` set): the
179    /// capsule slides along obstacles and settles under gravity, and the
180    /// rendered mesh follows it. The capsule stands on the mesh origin (its
181    /// feet), centred `half_height + radius` above it.
182    pub capsule: Option<CharacterCapsule>,
183    /// Injected at load time from the compiled blob payload.
184    #[serde(skip)]
185    pub locator: Option<PayloadLocator>,
186}
187
188/// A kinematic character capsule for a [SkinnedMesh](#skinnedmesh), in world
189/// units (after the mesh's `scale`).
190#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
191#[serde(default)]
192pub struct CharacterCapsule {
193    /// Half-height of the capsule's cylindrical section.
194    pub half_height: f32,
195    /// Capsule radius.
196    pub radius: f32,
197}
198
199impl Default for CharacterCapsule {
200    fn default() -> Self {
201        Self {
202            half_height: 0.5,
203            radius: 0.3,
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use alloc::vec;
212
213    #[test]
214    fn a_vertex_with_only_a_position_binds_fully_to_its_first_joint() {
215        // Importers emit position-only vertices for unweighted geometry; the
216        // defaults have to make that render white and rigid rather than black
217        // and collapsed to the origin.
218        let v: SkinnedVertexData = serde_json::from_str(r#"{"pos":[1,2,3]}"#).unwrap();
219        assert_eq!(v.pos, [1.0, 2.0, 3.0]);
220        assert_eq!(v.color, [1.0, 1.0, 1.0]);
221        assert_eq!(v.uv, [0.0, 0.0]);
222        assert_eq!(v.joints, [0, 0, 0, 0]);
223        assert_eq!(v.weights, [1.0, 0.0, 0.0, 0.0]);
224    }
225
226    #[test]
227    fn a_weighted_vertex_keeps_its_authored_joints_and_weights() {
228        let v: SkinnedVertexData = serde_json::from_str(
229            r#"{"pos":[0,0,0],"color":[0.5,0.5,0.5],"uv":[0.25,0.75],
230                "joints":[3,4,0,0],"weights":[0.6,0.4,0,0]}"#,
231        )
232        .unwrap();
233        assert_eq!(v.color, [0.5, 0.5, 0.5]);
234        assert_eq!(v.uv, [0.25, 0.75]);
235        assert_eq!(v.joints, [3, 4, 0, 0]);
236        assert_eq!(v.weights, [0.6, 0.4, 0.0, 0.0]);
237    }
238
239    #[test]
240    fn a_blank_joint_is_a_root_at_the_bind_pose_origin() {
241        let j = SkeletonJoint::default();
242        assert!(j.name.is_empty());
243        // -1 is the root marker; 0 would make every joint a child of joint 0.
244        assert_eq!(j.parent, -1);
245        assert_eq!(j.translation, [0.0, 0.0, 0.0]);
246        assert_eq!(j.scale, [1.0, 1.0, 1.0]);
247    }
248
249    #[test]
250    fn a_blank_morph_delta_moves_nothing() {
251        let d = MorphDelta::default();
252        assert_eq!(
253            d,
254            MorphDelta {
255                position: [0.0; 3],
256                normal: [0.0; 3],
257            }
258        );
259    }
260
261    #[test]
262    fn a_blank_mesh_has_no_geometry_and_no_capsule() {
263        let m = SkinnedMesh::default();
264        assert!(m.vertices.is_empty());
265        assert!(m.indices.is_empty());
266        assert!(m.morph_target_names.is_empty());
267        assert!(m.capsule.is_none());
268        assert!(m.locator.is_none());
269        assert_eq!(m.scale, [0.0, 0.0, 0.0]);
270        let c = CharacterCapsule::default();
271        assert_eq!((c.half_height, c.radius), (0.5, 0.3));
272    }
273
274    #[test]
275    fn an_imported_mesh_round_trips_through_postcard() {
276        crate::test_support::install_resolvers();
277        let m: SkinnedMesh = serde_json::from_str(
278            r#"{"source":"hero.glb","skin_index":1,"material":"skin_mat","texture":"skin_tex",
279                "vertices":[{"pos":[0,0,0]}],"indices":[0],
280                "morph_target_names":["smile"],"morph_deltas":[{"position":[0,0.1,0]}],
281                "position":[1,0,2],"scale":[1,1,1],"lod_levels":2,"lod_distances":[10],
282                "max_instances":4,"capsule":{"half_height":0.9,"radius":0.35}}"#,
283        )
284        .unwrap();
285        assert_eq!(m.material, Some(MaterialHandle(8)));
286        assert_eq!(m.texture, Some(TextureHandle(8)));
287        assert_eq!(m.morph_target_names, ["smile"]);
288
289        let bytes = postcard::to_allocvec(&m).unwrap();
290        let back: SkinnedMesh = postcard::from_bytes(&bytes).unwrap();
291        assert_eq!(back.source, "hero.glb");
292        assert_eq!(back.skin_index, 1);
293        assert_eq!(back.vertices[0].weights, [1.0, 0.0, 0.0, 0.0]);
294        assert_eq!(
295            back.morph_deltas,
296            vec![MorphDelta {
297                position: [0.0, 0.1, 0.0],
298                normal: [0.0; 3],
299            }]
300        );
301        assert_eq!(back.lod_distances, [10.0]);
302        assert_eq!(back.max_instances, 4);
303        assert_eq!(back.capsule.expect("capsule").half_height, 0.9);
304        // Identity and payload location are injected at load, never authored.
305        assert_eq!(back.asset_id, AssetId::default());
306        assert!(back.locator.is_none());
307    }
308}