Skip to main content

concinnity_core/components/
skinned_mesh.rs

1// src/components/skinned_mesh.rs
2//
3// Runtime behavior for the SkinnedMesh asset. The authored schema (SkinnedMesh,
4// its SkinnedVertexData / SkeletonJoint / CharacterCapsule, and their Defaults) lives
5// above; SkinnedMesh is a resource (compiled by cook into the
6// blob's resource stream, no `Component` impl), so this file keeps only the
7// skeleton builder and the `SkinnedMeshGeometry` extension trait that needs
8// `gfx::skeleton`.
9
10use crate::ecs::MaterialHandle;
11use crate::ecs::PayloadLocator;
12use crate::ecs::TextureHandle;
13use crate::ecs::asset_id::AssetId;
14use crate::ecs::de_opt_material_handle;
15use crate::ecs::de_opt_texture_handle;
16use alloc::string::String;
17use alloc::vec::Vec;
18
19fn white() -> [f32; 3] {
20    [1.0, 1.0, 1.0]
21}
22
23fn first_weight() -> [f32; 4] {
24    [1.0, 0.0, 0.0, 0.0]
25}
26
27/// One vertex of a skinned mesh. Beyond position / colour / uv it carries up
28/// to four joint bindings: `joints[k]` indexes the skeleton, `weights[k]` is
29/// its blend weight. Weights are normalised at build time.
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31pub struct SkinnedVertexData {
32    /// Vertex position `[x, y, z]` in model space.
33    pub pos: [f32; 3],
34    /// Vertex colour `[r, g, b]` in [0, 1]. Defaults to white.
35    #[serde(default = "white")]
36    pub color: [f32; 3],
37    /// Texture coordinates in [0, 1] space. Defaults to [0, 0].
38    #[serde(default)]
39    pub uv: [f32; 2],
40    /// Joint indices this vertex is bound to. Unused slots can be 0.
41    #[serde(default)]
42    pub joints: [u32; 4],
43    /// Blend weights parallel to `joints`. Defaults to fully bound to joint 0.
44    #[serde(default = "first_weight")]
45    pub weights: [f32; 4],
46}
47
48/// One morph-target vertex delta: offsets added to the bind-pose position and
49/// normal, scaled by the target's weight at runtime.
50#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
51#[serde(default)]
52pub struct MorphDelta {
53    /// Position offset `[x, y, z]` in model space.
54    pub position: [f32; 3],
55    /// Normal offset; the deformed normal is re-normalised after adding it.
56    pub normal: [f32; 3],
57}
58
59/// One joint of a skeleton's bind pose.
60#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
61#[serde(default)]
62pub struct SkeletonJoint {
63    /// Human-readable joint name (animation tracks may reference it later).
64    pub name: String,
65    /// Parent joint index, or -1 for a root. Parents must appear before their
66    /// children in the `skeleton` list.
67    pub parent: i32,
68    /// Local bind translation relative to the parent.
69    pub translation: [f32; 3],
70    /// Local bind rotation, Euler degrees [pitch, yaw, roll], YXZ order.
71    pub rotation_deg: [f32; 3],
72    /// Local bind scale.
73    pub scale: [f32; 3],
74}
75
76impl Default for SkeletonJoint {
77    fn default() -> Self {
78        Self {
79            name: String::new(),
80            parent: -1,
81            translation: [0.0, 0.0, 0.0],
82            rotation_deg: [0.0, 0.0, 0.0],
83            scale: [1.0, 1.0, 1.0],
84        }
85    }
86}
87
88/// A skeletally animated mesh placed directly in the world.
89///
90/// Unlike a [Mesh](#mesh), a `SkinnedMesh` carries its own world transform and a
91/// `skeleton` (a joint hierarchy with a bind pose). Each vertex is bound to up
92/// to four joints; an [Animation](#animation) targeting this mesh deforms it at
93/// runtime. With no animation the mesh renders in its bind pose.
94///
95/// The geometry + skeleton may be authored inline (`vertices` / `indices` /
96/// `skeleton`) or imported with `source` from a glTF (`.glb` / `.gltf`) or
97/// binary `.fbx` file. The import fills the mesh, the skeleton bind pose,
98/// and (for glTF) any morph targets; animations are imported separately by
99/// [Animation](#animation) assets referencing the same file.
100///
101/// The `customize_character` example ships a neutral unclothed body
102/// (`base_humanoid.glb`, about 21k vertices, A-pose bind) with a 25-joint
103/// skeleton (`root`, `hips`, `spine`, `chest`,
104/// `upper_chest`, `neck`, `head`, and `clavicle` / `upper_arm` / `forearm` /
105/// `hand` / `thumb` / `thigh` / `shin` / `foot` / `toe` with an `_l` / `_r`
106/// suffix), the morph targets a [CharacterShape](#charactershape) slider set
107/// names (`weight+/-`, `muscle`, `shoulders+/-`, `hips+/-`, `chest+/-`,
108/// `belly`, `head+/-`, `jaw+/-`, `nose+/-`, `brow`, `cheeks+/-`,
109/// `chin+/-`, `eyes+/-`, `lips+/-`, `mouth_width+/-`, `ears+/-`,
110/// `cheekbones+/-`), and a rotation-only `idle` clip an Animation can import from
111/// the same file; a [CharacterModel](#charactermodel) is the usual way to
112/// declare it.
113///
114/// The `skeleton` (joint hierarchy and bind pose) is provided as an arg
115/// (authored inline alongside `vertices`/`indices`, or filled in from the
116/// imported `.glb`) and is baked into the mesh at build time.
117///
118/// Normals and tangents are computed automatically at build time. Do not
119/// supply them.
120///
121/// ```rust
122/// # use concinnity_core::components::SkinnedMesh;
123/// SkinnedMesh {
124///     position: [0.0, 1.0, 0.0],
125///     ..Default::default()
126/// };
127/// ```
128#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
129#[serde(default)]
130pub struct SkinnedMesh {
131    /// Asset identity; injected via `inject_name`. Not part of `args`.
132    #[serde(skip)]
133    pub asset_id: AssetId,
134    /// Optional path to a `.glb` / `.gltf` / `.fbx` file. When set, the
135    /// build imports `vertices` / `indices` / `skeleton` from it; an
136    /// inline-authored mesh leaves this empty.
137    pub source: String,
138    /// Which skinned mesh of `source` to import, in file declaration order
139    /// (default 0). A character split into several meshes bound to one
140    /// skeleton (body, hair, clothes) needs one `SkinnedMesh` per part, each
141    /// naming its own index.
142    pub skin_index: u32,
143    /// Skinned vertex list.
144    pub vertices: Vec<SkinnedVertexData>,
145    /// Triangle index list.
146    pub indices: Vec<u16>,
147    /// Morph-target names, one per target, in target order. Filled from the
148    /// source file's target names when importing; empty for a mesh without
149    /// morph targets.
150    pub morph_target_names: Vec<String>,
151    /// Dense morph-target deltas, target-major: entry `t * vertex_count + v`
152    /// is target `t`'s delta for vertex `v`. Length must be
153    /// `morph_target_names.len() * vertices.len()`. An [Animation](#animation)
154    /// with a `morph_track` drives the per-target weights at runtime.
155    pub morph_deltas: Vec<MorphDelta>,
156    /// [Material](#material); provides the albedo texture plus lighting
157    /// parameters.
158    #[serde(deserialize_with = "de_opt_material_handle")]
159    pub material: Option<MaterialHandle>,
160    /// [Texture](#texture) (older path); ignored when `material` is set.
161    #[serde(deserialize_with = "de_opt_texture_handle")]
162    pub texture: Option<TextureHandle>,
163    /// World-space position.
164    pub position: [f32; 3],
165    /// World rotation, Euler degrees [pitch, yaw, roll], YXZ order.
166    pub rotation_deg: [f32; 3],
167    /// World scale.
168    pub scale: [f32; 3],
169    /// Number of level-of-detail versions to generate, including the original.
170    /// `1` (the default) generates none; values are clamped to `[1, 8]`.
171    pub lod_levels: u32,
172    /// Camera distances at which to switch to each lower-detail version. When
173    /// non-empty, must have exactly `lod_levels - 1` entries; empty lets the
174    /// build choose defaults.
175    #[serde(default)]
176    pub lod_distances: Vec<f32>,
177    /// How many runtime copies of this mesh may exist at once beyond the
178    /// authored one. `0` (the default) means the mesh is not runtime-spawnable.
179    /// A non-zero value pre-reserves that many extra instance slots at load: the
180    /// engine appends that many hidden bind-pose copies to the skinned geometry
181    /// so a runtime spawn can claim one without growing any GPU buffer, and a
182    /// despawn returns it to the pool. Spawns past the reserve are dropped (a
183    /// warning is logged). Capped at 4096.
184    pub max_instances: u32,
185    /// Optional character capsule. When set, the mesh collides with the
186    /// scene as a kinematic character and is moved by the root motion of its
187    /// [Animation](#animation) clips (those with `root_motion` set): the
188    /// capsule slides along obstacles and settles under gravity, and the
189    /// rendered mesh follows it. The capsule stands on the mesh origin (its
190    /// feet), centred `half_height + radius` above it.
191    pub capsule: Option<CharacterCapsule>,
192    /// Injected at load time from the compiled blob payload.
193    #[serde(skip)]
194    pub locator: Option<PayloadLocator>,
195}
196
197/// A kinematic character capsule for a [SkinnedMesh](#skinnedmesh), in world
198/// units (after the mesh's `scale`).
199#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
200#[serde(default)]
201pub struct CharacterCapsule {
202    /// Half-height of the capsule's cylindrical section.
203    pub half_height: f32,
204    /// Capsule radius.
205    pub radius: f32,
206}
207
208impl Default for CharacterCapsule {
209    fn default() -> Self {
210        Self {
211            half_height: 0.5,
212            radius: 0.3,
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use alloc::vec;
221
222    #[test]
223    fn a_vertex_with_only_a_position_binds_fully_to_its_first_joint() {
224        // Importers emit position-only vertices for unweighted geometry; the
225        // defaults have to make that render white and rigid rather than black
226        // and collapsed to the origin.
227        let v: SkinnedVertexData = serde_json::from_str(r#"{"pos":[1,2,3]}"#).unwrap();
228        assert_eq!(v.pos, [1.0, 2.0, 3.0]);
229        assert_eq!(v.color, [1.0, 1.0, 1.0]);
230        assert_eq!(v.uv, [0.0, 0.0]);
231        assert_eq!(v.joints, [0, 0, 0, 0]);
232        assert_eq!(v.weights, [1.0, 0.0, 0.0, 0.0]);
233    }
234
235    #[test]
236    fn a_weighted_vertex_keeps_its_authored_joints_and_weights() {
237        let v: SkinnedVertexData = serde_json::from_str(
238            r#"{"pos":[0,0,0],"color":[0.5,0.5,0.5],"uv":[0.25,0.75],
239                "joints":[3,4,0,0],"weights":[0.6,0.4,0,0]}"#,
240        )
241        .unwrap();
242        assert_eq!(v.color, [0.5, 0.5, 0.5]);
243        assert_eq!(v.uv, [0.25, 0.75]);
244        assert_eq!(v.joints, [3, 4, 0, 0]);
245        assert_eq!(v.weights, [0.6, 0.4, 0.0, 0.0]);
246    }
247
248    #[test]
249    fn a_blank_joint_is_a_root_at_the_bind_pose_origin() {
250        let j = SkeletonJoint::default();
251        assert!(j.name.is_empty());
252        // -1 is the root marker; 0 would make every joint a child of joint 0.
253        assert_eq!(j.parent, -1);
254        assert_eq!(j.translation, [0.0, 0.0, 0.0]);
255        assert_eq!(j.scale, [1.0, 1.0, 1.0]);
256    }
257
258    #[test]
259    fn a_blank_morph_delta_moves_nothing() {
260        let d = MorphDelta::default();
261        assert_eq!(
262            d,
263            MorphDelta {
264                position: [0.0; 3],
265                normal: [0.0; 3],
266            }
267        );
268    }
269
270    #[test]
271    fn a_blank_mesh_has_no_geometry_and_no_capsule() {
272        let m = SkinnedMesh::default();
273        assert!(m.vertices.is_empty());
274        assert!(m.indices.is_empty());
275        assert!(m.morph_target_names.is_empty());
276        assert!(m.capsule.is_none());
277        assert!(m.locator.is_none());
278        assert_eq!(m.scale, [0.0, 0.0, 0.0]);
279        let c = CharacterCapsule::default();
280        assert_eq!((c.half_height, c.radius), (0.5, 0.3));
281    }
282
283    #[test]
284    fn an_imported_mesh_round_trips_through_postcard() {
285        crate::test_support::install_resolvers();
286        let m: SkinnedMesh = serde_json::from_str(
287            r#"{"source":"hero.glb","skin_index":1,"material":"skin_mat","texture":"skin_tex",
288                "vertices":[{"pos":[0,0,0]}],"indices":[0],
289                "morph_target_names":["smile"],"morph_deltas":[{"position":[0,0.1,0]}],
290                "position":[1,0,2],"scale":[1,1,1],"lod_levels":2,"lod_distances":[10],
291                "max_instances":4,"capsule":{"half_height":0.9,"radius":0.35}}"#,
292        )
293        .unwrap();
294        assert_eq!(m.material, Some(MaterialHandle(8)));
295        assert_eq!(m.texture, Some(TextureHandle(8)));
296        assert_eq!(m.morph_target_names, ["smile"]);
297
298        let bytes = postcard::to_allocvec(&m).unwrap();
299        let back: SkinnedMesh = postcard::from_bytes(&bytes).unwrap();
300        assert_eq!(back.source, "hero.glb");
301        assert_eq!(back.skin_index, 1);
302        assert_eq!(back.vertices[0].weights, [1.0, 0.0, 0.0, 0.0]);
303        assert_eq!(
304            back.morph_deltas,
305            vec![MorphDelta {
306                position: [0.0, 0.1, 0.0],
307                normal: [0.0; 3],
308            }]
309        );
310        assert_eq!(back.lod_distances, [10.0]);
311        assert_eq!(back.max_instances, 4);
312        assert_eq!(back.capsule.expect("capsule").half_height, 0.9);
313        // Identity and payload location are injected at load, never authored.
314        assert_eq!(back.asset_id, AssetId::default());
315        assert!(back.locator.is_none());
316    }
317}
318
319/// Build a runtime `Skeleton` from authored joint definitions. Mirrors the
320/// conversion `GraphicsSystem::init` does at world load time: each
321/// `SkeletonJoint.parent` becomes `Some(usize)` for valid indices (negative values
322/// mark roots), and each `SkeletonJoint`'s translation / rotation / scale becomes the
323/// joint's bind `JointPose`. Used at init and by the asset hot-reload's
324/// skeleton-shape change path.
325pub fn build_skeleton_from_joint_defs(defs: &[SkeletonJoint]) -> crate::gfx::skeleton::Skeleton {
326    use crate::gfx::skeleton as skinning;
327    let joints = defs
328        .iter()
329        .map(|jd| skinning::Joint {
330            name: jd.name.clone(),
331            parent: (jd.parent >= 0).then_some(jd.parent as usize),
332            bind: skinning::JointPose {
333                translation: jd.translation,
334                rotation_deg: jd.rotation_deg,
335                scale: jd.scale,
336            },
337        })
338        .collect();
339    skinning::Skeleton::new(joints)
340}
341
342/// Column-major world matrix from a SkinnedMesh's transform. Kept in core (not
343/// the schema half) because the matrix build goes through `gfx::skeleton`, which
344/// needs std transcendentals. Exposed as an extension trait so call sites keep
345/// method syntax (`sm.model_matrix()`), matching `geometry.rs`.
346pub trait SkinnedMeshGeometry {
347    /// Column-major world matrix built from the mesh's transform.
348    fn model_matrix(&self) -> [[f32; 4]; 4];
349}
350
351impl SkinnedMeshGeometry for SkinnedMesh {
352    // Same construction order (scale, YXZ rotation, translate) as
353    // `Prop::model_matrix`.
354    fn model_matrix(&self) -> [[f32; 4]; 4] {
355        crate::gfx::skeleton::JointPose {
356            translation: self.position,
357            rotation_deg: self.rotation_deg,
358            scale: self.scale,
359        }
360        .to_matrix()
361    }
362}
363
364#[cfg(test)]
365mod runtime_tests {
366    use super::*;
367    use crate::components::{CharacterCapsule, SkinnedVertexData};
368    use alloc::vec;
369
370    #[test]
371    fn build_skeleton_from_joint_defs_preserves_count_and_parent_links() {
372        let defs = vec![
373            SkeletonJoint {
374                name: "root".into(),
375                parent: -1,
376                translation: [0.0, 0.0, 0.0],
377                rotation_deg: [0.0, 0.0, 0.0],
378                scale: [1.0, 1.0, 1.0],
379            },
380            SkeletonJoint {
381                name: "tip".into(),
382                parent: 0,
383                translation: [0.0, 1.0, 0.0],
384                rotation_deg: [0.0, 0.0, 0.0],
385                scale: [1.0, 1.0, 1.0],
386            },
387            SkeletonJoint {
388                name: "tail".into(),
389                parent: 1,
390                translation: [0.0, 1.0, 0.0],
391                rotation_deg: [0.0, 0.0, 0.0],
392                scale: [1.0, 1.0, 1.0],
393            },
394        ];
395        let skel = build_skeleton_from_joint_defs(&defs);
396        assert_eq!(skel.len(), 3);
397        let joints = skel.joints();
398        assert_eq!(joints[0].parent, None);
399        assert_eq!(joints[1].parent, Some(0));
400        assert_eq!(joints[2].parent, Some(1));
401    }
402
403    #[test]
404    fn build_skeleton_from_joint_defs_treats_negative_parent_as_root() {
405        // Any negative parent (not just -1) collapses to None; mirrors the
406        // init-time semantics so a hot-reload from the same SkeletonJoint shape
407        // produces the same Skeleton.
408        let defs = vec![SkeletonJoint {
409            name: "root".into(),
410            parent: -42,
411            translation: [1.0, 2.0, 3.0],
412            rotation_deg: [0.0, 0.0, 0.0],
413            scale: [1.0, 1.0, 1.0],
414        }];
415        let skel = build_skeleton_from_joint_defs(&defs);
416        assert_eq!(skel.joints()[0].parent, None);
417    }
418
419    #[test]
420    fn model_matrix_places_translation_in_last_column() {
421        let mesh = SkinnedMesh {
422            position: [2.0, 3.0, 4.0],
423            scale: [1.0, 1.0, 1.0],
424            ..SkinnedMesh::default()
425        };
426        let m = mesh.model_matrix();
427        // Column-major: the translation lives in the last column, identity
428        // scale keeps the diagonal at 1.
429        assert_eq!([m[3][0], m[3][1], m[3][2]], [2.0, 3.0, 4.0]);
430        assert_eq!(m[3][3], 1.0);
431        assert_eq!(m[0][0], 1.0);
432    }
433
434    #[test]
435    fn skinned_vertex_defaults_fill_color_uv_and_weights() {
436        // A vertex authored with only a position picks up the serde defaults:
437        // white colour, zero uv, and full weight on joint 0.
438        let v: SkinnedVertexData =
439            serde_json::from_value(serde_json::json!({"pos": [0.0, 0.0, 0.0]})).unwrap();
440        assert_eq!(v.color, [1.0, 1.0, 1.0]);
441        assert_eq!(v.uv, [0.0, 0.0]);
442        assert_eq!(v.weights, [1.0, 0.0, 0.0, 0.0]);
443        assert_eq!(v.joints, [0, 0, 0, 0]);
444    }
445
446    #[test]
447    fn capsule_joint_defaults() {
448        let cap = CharacterCapsule::default();
449        assert_eq!(cap.half_height, 0.5);
450        assert_eq!(cap.radius, 0.3);
451
452        let jd: SkeletonJoint = serde_json::from_value(serde_json::json!({})).unwrap();
453        assert_eq!(jd.parent, -1);
454        assert_eq!(jd.scale, [1.0, 1.0, 1.0]);
455    }
456}