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}