Skip to main content

concinnity_core/components/
character_rig.rs

1// src/components/character_rig.rs
2
3use crate::ecs::SkinnedMeshHandle;
4use crate::gfx::transform::Mat4;
5use crate::math::sin_cos;
6
7/// Runtime-only link between a skinned mesh and its character capsule.
8///
9/// `GraphicsSystem` publishes one `CharacterRig` per `SkinnedMesh` that
10/// declares a `capsule`, carrying the authored model transform and capsule
11/// dimensions. `PhysicsSystem` creates the kinematic capsule from it, then
12/// each frame consumes the target's `RootMotionEvent` events, resolves the
13/// displacement against the scene, and writes the new `position` back here;
14/// `GraphicsSystem` moves the rendered mesh to follow. The one-frame
15/// producer/consumer hand-offs are invisible at animation rates.
16///
17/// Not authored in world files: it has no `args`.
18#[derive(Debug, Clone)]
19pub struct CharacterRig {
20    /// The `SkinnedMesh` resource this rig moves.
21    pub target: SkinnedMeshHandle,
22    /// Index of the mesh's skinned draw object in the render backend.
23    pub skinned_index: usize,
24    /// The mesh's authored model matrix. Root-motion deltas are mapped
25    /// through its rotation/scale, and the moved mesh keeps its orientation.
26    pub base_model: Mat4,
27    /// Current mesh-origin position in world space. Seeded to the authored
28    /// position; overwritten by `PhysicsSystem` as the capsule moves.
29    pub position: [f32; 3],
30    /// Capsule half-height (cylindrical section) in world units.
31    pub half_height: f32,
32    /// Capsule radius in world units.
33    pub radius: f32,
34    /// Runtime facing yaw in radians, applied on top of the authored
35    /// rotation (about world Y). Written by a character controller; `0`
36    /// keeps the authored facing.
37    pub yaw: f32,
38    /// World-space drive velocity in units per second, added to the
39    /// root-motion displacement each physics step. Written every frame by a
40    /// direct-drive character controller; stays zero otherwise.
41    pub desired_move: [f32; 3],
42    /// One-shot takeoff velocity in units per second. Consumed by
43    /// `PhysicsSystem` on the next step: applied if the capsule is grounded,
44    /// discarded either way.
45    pub jump_velocity: f32,
46    /// Whether the capsule rested on ground after the last move.
47    pub grounded: bool,
48    /// Set by `PhysicsSystem` when `position` changed and by a controller
49    /// when `yaw` changed; cleared by `GraphicsSystem` once the render
50    /// transform caught up.
51    pub moved: bool,
52}
53
54impl CharacterRig {
55    /// A rig at its authored placement. `base_model` is the mesh's model
56    /// matrix; its translation column doubles as the starting position.
57    pub fn new(
58        target: SkinnedMeshHandle,
59        skinned_index: usize,
60        base_model: Mat4,
61        half_height: f32,
62        radius: f32,
63    ) -> Self {
64        Self {
65            target,
66            skinned_index,
67            base_model,
68            position: [base_model[3][0], base_model[3][1], base_model[3][2]],
69            half_height,
70            radius,
71            yaw: 0.0,
72            desired_move: [0.0; 3],
73            jump_velocity: 0.0,
74            grounded: false,
75            moved: false,
76        }
77    }
78
79    /// The current model matrix: the authored rotation/scale, turned by the
80    /// runtime facing `yaw`, at the live position.
81    pub fn model(&self) -> Mat4 {
82        let mut m = self.base_model;
83        for col in m.iter_mut().take(3) {
84            let turned = self.turn([col[0], col[1], col[2]]);
85            col[0] = turned[0];
86            col[1] = turned[1];
87            col[2] = turned[2];
88        }
89        m[3][0] = self.position[0];
90        m[3][1] = self.position[1];
91        m[3][2] = self.position[2];
92        m
93    }
94
95    /// Map a mesh-local displacement into world space through the authored
96    /// rotation/scale (the linear part of `base_model`) and the runtime
97    /// facing `yaw`.
98    pub fn world_delta(&self, local: [f32; 3]) -> [f32; 3] {
99        let m = &self.base_model;
100        self.turn([
101            m[0][0] * local[0] + m[1][0] * local[1] + m[2][0] * local[2],
102            m[0][1] * local[0] + m[1][1] * local[1] + m[2][1] * local[2],
103            m[0][2] * local[0] + m[1][2] * local[1] + m[2][2] * local[2],
104        ])
105    }
106
107    // Rotate a world-space vector by the runtime facing yaw (about world Y).
108    fn turn(&self, v: [f32; 3]) -> [f32; 3] {
109        let (s, c) = sin_cos(self.yaw);
110        [v[0] * c + v[2] * s, v[1], v[2] * c - v[0] * s]
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn world_delta_maps_through_rotation_and_scale() {
120        // 90-degree yaw + uniform scale 2: local +Z becomes world +X, doubled.
121        let pose = crate::gfx::skeleton::JointPose {
122            translation: [5.0, 0.0, 1.0],
123            rotation_deg: [0.0, 90.0, 0.0],
124            scale: [2.0, 2.0, 2.0],
125        };
126        let rig = CharacterRig::new(SkinnedMeshHandle(1), 0, pose.to_matrix(), 0.5, 0.3);
127        assert_eq!(rig.position, [5.0, 0.0, 1.0]);
128        let d = rig.world_delta([0.0, 0.0, 1.0]);
129        assert!((d[0] - 2.0).abs() < 1e-4, "{d:?}");
130        assert!(d[1].abs() < 1e-4 && d[2].abs() < 1e-4, "{d:?}");
131    }
132
133    #[test]
134    fn yaw_turns_local_forward_to_the_heading() {
135        // Facing yaw pi/2: local -Z travel becomes world -X (the camera
136        // convention, where yaw 0 looks down -Z).
137        let mut rig = CharacterRig::new(
138            SkinnedMeshHandle(1),
139            0,
140            crate::gfx::transform::IDENTITY,
141            0.5,
142            0.3,
143        );
144        rig.yaw = core::f32::consts::FRAC_PI_2;
145        let d = rig.world_delta([0.0, 0.0, -1.0]);
146        assert!((d[0] + 1.0).abs() < 1e-4, "{d:?}");
147        assert!(d[1].abs() < 1e-4 && d[2].abs() < 1e-4, "{d:?}");
148        // The model matrix turns the same way: its third column (local +Z)
149        // lands on world +X.
150        let m = rig.model();
151        assert!(
152            (m[2][0] - 1.0).abs() < 1e-4 && m[2][2].abs() < 1e-4,
153            "{m:?}"
154        );
155        // Yaw composes on top of the authored rotation: with a 90-degree
156        // authored yaw as well, local -Z ends up at world +Z (180 total).
157        let pose = crate::gfx::skeleton::JointPose {
158            translation: [0.0; 3],
159            rotation_deg: [0.0, 90.0, 0.0],
160            scale: [1.0, 1.0, 1.0],
161        };
162        let mut rig = CharacterRig::new(SkinnedMeshHandle(1), 0, pose.to_matrix(), 0.5, 0.3);
163        rig.yaw = core::f32::consts::FRAC_PI_2;
164        let d = rig.world_delta([0.0, 0.0, -1.0]);
165        assert!((d[2] - 1.0).abs() < 1e-4, "{d:?}");
166        assert!(d[0].abs() < 1e-4 && d[1].abs() < 1e-4, "{d:?}");
167    }
168
169    #[test]
170    fn model_replaces_translation_only() {
171        let pose = crate::gfx::skeleton::JointPose {
172            translation: [1.0, 2.0, 3.0],
173            rotation_deg: [0.0, 45.0, 0.0],
174            scale: [1.0, 1.0, 1.0],
175        };
176        let mut rig = CharacterRig::new(SkinnedMeshHandle(1), 0, pose.to_matrix(), 0.5, 0.3);
177        rig.position = [9.0, 2.0, -4.0];
178        let m = rig.model();
179        assert_eq!([m[3][0], m[3][1], m[3][2]], [9.0, 2.0, -4.0]);
180        // Rotation/scale columns untouched.
181        assert_eq!(m[0], rig.base_model[0]);
182        assert_eq!(m[1], rig.base_model[1]);
183        assert_eq!(m[2], rig.base_model[2]);
184    }
185}