Skip to main content

vtuber_joints_example/
vtuber-joints-example.rs

1use mittens_engine::engine::ecs::component::{
2    AmbientLightComponent, BackgroundColorComponent, Camera3DComponent, CameraXRComponent,
3    ClockComponent, ColorComponent, DirectionalLightComponent, EditorComponent, EmissiveComponent,
4    GLTFComponent, InputComponent, InputTransformModeComponent, InputXRComponent, MeshComponent,
5    PointerComponent, RaycastableComponent, RenderableComponent, SkinnedMeshComponent,
6    TransformComponent,
7};
8use mittens_engine::{engine, utils};
9use std::collections::{HashMap, HashSet};
10
11#[path = "example_util/mod.rs"]
12mod example_util;
13
14fn main() {
15    mittens_engine::example_support::ensure_model_assets();
16    utils::logger::init();
17
18    let world = engine::ecs::World::default();
19    let mut universe = engine::Universe::new(world);
20
21    // Slow the global beat clock so beat-based animations run half as fast.
22    let clock = universe
23        .world
24        .add_component(ClockComponent::new().with_bpm(60.0));
25    universe.add(clock);
26
27    // Light pink background.
28    let background = universe
29        .world
30        .add_component(BackgroundColorComponent::new());
31    let background_c = universe
32        .world
33        .add_component(ColorComponent::rgba(1.0, 0.82, 0.90, 1.0));
34    let _ = universe.world.add_child(background, background_c);
35    universe.add(background);
36
37    // Small ambient so shadowed areas aren't pure black.
38    let ambient = universe
39        .world
40        .add_component(AmbientLightComponent::rgb(0.10, 0.10, 0.12));
41    universe.add(ambient);
42
43    // --- Camera rig (WASD + mouse) ---
44    let input = universe
45        .world
46        .add_component(InputComponent::new().with_speed(1.5));
47    let input_mode = universe.world.add_component(
48        InputTransformModeComponent::forward_z()
49            .with_fps_rotation()
50            .with_roll_axis_y(),
51    );
52    let _ = universe.attach(input, input_mode);
53
54    // Start slightly pulled back looking towards the origin.
55    let rig_transform = universe
56        .world
57        .add_component(TransformComponent::new().with_position(0.0, 0.0, 6.0));
58    let _ = universe.attach(input, rig_transform);
59
60    let camera3d = universe.world.add_component(Camera3DComponent::new());
61    let _ = universe.attach(rig_transform, camera3d);
62
63    // Pointer so gizmos can be interacted with.
64    let pointer = universe.world.add_component(PointerComponent::new());
65    let _ = universe.attach(camera3d, pointer);
66
67    // Topology: I { T { C3D } } — add a small camera-attached controls hint.
68    example_util::spawn_desktop_camera_controls_hint(&mut universe, rig_transform);
69
70    universe.add(input);
71
72    // --- lighting ---
73    let sun = universe.world.add_component(
74        DirectionalLightComponent::new()
75            .with_intensity(1.2)
76            .with_color(1.0, 0.98, 0.94),
77    );
78    let sun_dir = universe
79        .world
80        .add_component(TransformComponent::new().with_position(0.0, -0.35, 1.0));
81    let _ = universe.attach(sun_dir, sun);
82    universe.add(sun_dir);
83
84    let light_transform = universe.world.add_component(
85        TransformComponent::new()
86            .with_position(1.0, 6.0, 3.0)
87            .with_scale(0.1, 0.1, 0.1),
88    );
89    let light = universe.world.add_component(
90        engine::ecs::component::PointLightComponent::new()
91            .with_distance(120.0)
92            .with_color(1.0, 1.0, 1.0),
93    );
94    let _ = universe.attach(light_transform, light);
95    universe.add(light_transform);
96
97    // --- VTuber model ---
98    let model_uri = "assets/models/pc-rei.hoodie.glb";
99
100    // Wrap the model subtree in an editor root so transform-only glTF nodes can be visualized
101    // (and thus raycasted/selected) without affecting non-editor scenes.
102    let editor_root = universe.world.add_component(EditorComponent::new());
103
104    let model_root = universe.world.add_component(TransformComponent::new());
105    let model = universe.world.add_component(GLTFComponent::new(model_uri));
106
107    // emissive for pc-rei
108    let emissive = universe.world.add_component(EmissiveComponent::on());
109    let _ = universe.attach(model, emissive);
110
111    let xr_input = universe.world.add_component(InputXRComponent::on());
112    let xr_gamepad = universe
113        .world
114        .add_component(engine::ecs::component::InputXRGamepadComponent::new().speed(1.5));
115    let xr_head = universe.world.add_component(TransformComponent::new());
116    let xr_camera = universe.world.add_component(CameraXRComponent::on());
117    let _ = universe.attach(xr_input, xr_head);
118    let _ = universe.attach(xr_input, xr_gamepad);
119    let _ = universe.attach(xr_head, xr_camera);
120    let xr_pointer = universe.world.add_component(PointerComponent::new());
121    let _ = universe.attach(xr_camera, xr_pointer);
122    let _ = universe.attach(xr_head, editor_root);
123
124    let _ = universe.attach(model_root, model);
125
126    let _ = universe.attach(editor_root, model_root);
127
128    // Initialize the editor root so GLTFComponent gets registered.
129    universe.add(xr_input);
130    universe.add(editor_root);
131
132    // --- Background clouds (occluded + lit) ---
133    let bg_root = universe.world.add_component(
134        engine::ecs::component::BackgroundComponent::new().with_occlusion_and_lighting(),
135    );
136    universe.add(bg_root);
137    let mut cloud_params = example_util::CloudRingParams::default();
138    cloud_params.cloud_count = 8; // +3 clusters
139    cloud_params.angle_jitter = 0.35;
140    cloud_params.high_y_probability = 0.5;
141    cloud_params.high_y_multiplier = 1.5;
142    cloud_params.seed = 0x57_55_B0_01u32;
143    example_util::spawn_cloud_ring(&mut universe, bg_root, cloud_params);
144
145    // --- Simple environment ---
146    let spawn_cube = |universe: &mut engine::Universe,
147                      position: (f32, f32, f32),
148                      scale: (f32, f32, f32),
149                      color: (f32, f32, f32, f32)| {
150        let transform = universe.world.add_component(
151            TransformComponent::new()
152                .with_position(position.0, position.1, position.2)
153                .with_scale(scale.0, scale.1, scale.2),
154        );
155        let renderable = universe.world.add_component(RenderableComponent::cube());
156        let color = universe
157            .world
158            .add_component(ColorComponent::rgba(color.0, color.1, color.2, color.3));
159
160        let _ = universe.attach(transform, renderable);
161        let _ = universe.attach(renderable, color);
162
163        universe.add(transform);
164    };
165
166    // floor
167    spawn_cube(
168        &mut universe,
169        (0.0, -0.05, 0.0),
170        (10.0, 0.1, 10.0),
171        (0.92, 0.92, 0.92, 1.0),
172    );
173
174    // back wall
175    spawn_cube(
176        &mut universe,
177        (-3.0, 1.5, -5.0),
178        (3.0, 3.0, 1.0),
179        (0.95, 0.94, 0.96, 1.0),
180    );
181
182    // desk
183    spawn_cube(
184        &mut universe,
185        (0.0, 0.35, 1.0),
186        (1.0, 0.75, 0.5),
187        (0.75, 0.70, 0.65, 1.0),
188    );
189
190    // --- Editor-side stacked cubes (inside the editor subtree for picking/gizmos) ---
191    {
192        let spawn_editor_cube = |universe: &mut engine::Universe,
193                                 editor_root: engine::ecs::ComponentId,
194                                 name: &str,
195                                 position: (f32, f32, f32),
196                                 scale: (f32, f32, f32),
197                                 color: (f32, f32, f32, f32)| {
198            let transform = universe.world.add_component_boxed_named(
199                format!("{name}_t"),
200                Box::new(
201                    TransformComponent::new()
202                        .with_position(position.0, position.1, position.2)
203                        .with_scale(scale.0, scale.1, scale.2),
204                ),
205            );
206            let renderable = universe.world.add_component_boxed_named(
207                format!("{name}_r"),
208                Box::new(RenderableComponent::cube()),
209            );
210            let color_comp = universe.world.add_component_boxed_named(
211                format!("{name}_color"),
212                Box::new(ColorComponent::rgba(color.0, color.1, color.2, color.3)),
213            );
214            let raycastable = universe.world.add_component_boxed_named(
215                format!("{name}_raycastable"),
216                Box::new(RaycastableComponent::enabled()),
217            );
218
219            let _ = universe.world.add_child(transform, renderable);
220            let _ = universe.world.add_child(renderable, color_comp);
221            let _ = universe.world.add_child(renderable, raycastable);
222
223            // One attach into the initialized editor subtree triggers init for the new subtree.
224            let _ = universe.attach(editor_root, transform);
225        };
226
227        // Place the stack beside the desk (a bit to the right).
228        let stack_x = 1.35;
229        let stack_z = 1.0;
230        let s = 0.25;
231        let half = 0.5 * s;
232        let light_brown = (0.80, 0.72, 0.55, 1.0);
233        let cyan = (0.20, 1.00, 1.00, 1.0);
234
235        spawn_editor_cube(
236            &mut universe,
237            editor_root,
238            "editor_stack_0",
239            (stack_x, half, stack_z),
240            (s, s, s),
241            light_brown,
242        );
243        spawn_editor_cube(
244            &mut universe,
245            editor_root,
246            "editor_stack_1",
247            (stack_x, half + 1.0 * s, stack_z),
248            (s, s, s),
249            light_brown,
250        );
251        spawn_editor_cube(
252            &mut universe,
253            editor_root,
254            "editor_stack_2",
255            (stack_x, half + 2.0 * s, stack_z),
256            (s, s, s),
257            light_brown,
258        );
259        spawn_editor_cube(
260            &mut universe,
261            editor_root,
262            "editor_stack_top",
263            (stack_x, half + 3.0 * s, stack_z),
264            (s, s, s),
265            cyan,
266        );
267    }
268
269    let xr_root = universe
270        .world
271        .add_component(engine::ecs::component::XrComponent::on());
272    universe.add(xr_root);
273
274    universe.systems.process_commands(
275        &mut universe.world,
276        &mut universe.visuals,
277        &mut universe.render_assets,
278        &mut universe.command_queue,
279    );
280
281    // Spawn the glTF subtree once up-front so joint ComponentIds exist.
282    {
283        let systems = &mut universe.systems;
284        systems.gltf.tick_with_queue(
285            &mut universe.world,
286            &mut universe.visuals,
287            &mut systems.skinned_mesh,
288            &mut universe.command_queue,
289            0.0,
290        );
291    }
292    universe.systems.process_commands(
293        &mut universe.world,
294        &mut universe.visuals,
295        &mut universe.render_assets,
296        &mut universe.command_queue,
297    );
298
299    // Register imported meshes into RenderAssets early so we can inspect skin weights
300    // (textures will still be uploaded later during normal rendering).
301    universe
302        .systems
303        .gltf
304        .flush_mesh_imports_only(&mut universe.render_assets);
305
306    // --- Joint printout + animation binding (in the example) ---
307    let all_joints = collect_joint_transforms(
308        &universe.world,
309        &universe.systems.skinned_mesh,
310        &universe.visuals,
311        model,
312        model_root,
313    );
314    println!("[vtuber-joints-example] joints found: {}", all_joints.len());
315    for (i, (node_index, joint_tx)) in all_joints.iter().enumerate() {
316        println!("  joint[{i:03}] node_index={node_index} transform={joint_tx:?}");
317    }
318
319    let node_index_to_transform: HashMap<usize, engine::ecs::ComponentId> =
320        all_joints.iter().copied().collect();
321
322    // Example settings are hardcoded to keep this example simple.
323    let joint_offset: usize = 0;
324    let wiggle_count: usize = 16;
325
326    let target_mesh_key = "pc-rei.hoodie:Body_(merged).baked:prim0".to_string();
327    let target_joint_names: Vec<String> = vec![
328        "J_Bip_L_UpperArm".to_string(),
329        "J_Bip_R_UpperArm".to_string(),
330    ];
331
332    let print_transform_updates: bool = false;
333
334    let selected_joint_transforms: Vec<(usize, engine::ecs::ComponentId)> =
335        select_named_joints(&universe.world, &all_joints, &target_joint_names)
336            .or_else(|| {
337                select_body_prim0_influencers(
338                    &universe,
339                    model_root,
340                    &target_mesh_key,
341                    wiggle_count,
342                    &node_index_to_transform,
343                )
344            })
345            .unwrap_or_else(|| select_joint_range(&all_joints, joint_offset, wiggle_count));
346    println!(
347        "[vtuber-joints-example] wiggle selection: target_mesh_key='{}' offset={} count={} selected={}",
348        target_mesh_key,
349        joint_offset,
350        wiggle_count,
351        selected_joint_transforms.len()
352    );
353
354    debug_print_selected_joint_influence(
355        &universe,
356        &target_mesh_key,
357        model_root,
358        &selected_joint_transforms,
359    );
360
361    println!("[vtuber-joints-example] selected joints:");
362    for (i, (node_index, joint_tx)) in selected_joint_transforms.iter().enumerate() {
363        let name = universe
364            .world
365            .get_component_record(*joint_tx)
366            .map(|n| n.name.as_str())
367            .unwrap_or("<unknown>");
368        println!("  sel[{i:02}] node_index={node_index} name={name} transform={joint_tx:?}");
369    }
370
371    if print_transform_updates {
372        println!("[vtuber-joints-example] note: joint animation disabled");
373    }
374
375    universe.systems.process_commands(
376        &mut universe.world,
377        &mut universe.visuals,
378        &mut universe.render_assets,
379        &mut universe.command_queue,
380    );
381
382    universe.enable_repl();
383    engine::Windowing::run_app(universe).expect("Windowing failed");
384}
385
386fn select_named_joints(
387    world: &engine::ecs::World,
388    all_joints: &[(usize, engine::ecs::ComponentId)],
389    names: &[String],
390) -> Option<Vec<(usize, engine::ecs::ComponentId)>> {
391    if names.is_empty() {
392        return None;
393    }
394
395    let mut out: Vec<(usize, engine::ecs::ComponentId)> = Vec::new();
396    for wanted in names {
397        let mut found: Option<(usize, engine::ecs::ComponentId)> = None;
398        for &(node_index, joint_tx) in all_joints.iter() {
399            let name = world
400                .get_component_record(joint_tx)
401                .map(|r| r.name.as_str())
402                .unwrap_or("");
403            if name == wanted {
404                found = Some((node_index, joint_tx));
405                break;
406            }
407        }
408
409        if let Some(v) = found {
410            out.push(v);
411        } else {
412            println!(
413                "[vtuber-joints-example] warning: target joint '{}' not found (check names via REPL tree)",
414                wanted
415            );
416        }
417    }
418
419    if out.is_empty() { None } else { Some(out) }
420}
421
422fn debug_print_selected_joint_influence(
423    universe: &engine::Universe,
424    mesh_key: &str,
425    model_root: engine::ecs::ComponentId,
426    selected: &[(usize, engine::ecs::ComponentId)],
427) {
428    let Some(renderable) = find_renderable_by_mesh_key(&universe.world, model_root, mesh_key)
429    else {
430        println!(
431            "[vtuber-joints-example] influence: mesh_key='{}' renderable not found",
432            mesh_key
433        );
434        return;
435    };
436
437    let renderable_handle = universe
438        .world
439        .get_component_by_id_as::<RenderableComponent>(renderable)
440        .and_then(|r| r.get_handle());
441    println!(
442        "[vtuber-joints-example] influence: mesh_key='{}' renderable={:?} instance_handle={:?}",
443        mesh_key, renderable, renderable_handle
444    );
445    let Some(skin_id) = find_skin_id_for_renderable(&universe.world, renderable) else {
446        println!(
447            "[vtuber-joints-example] influence: mesh_key='{}' has no skin_id",
448            mesh_key
449        );
450        return;
451    };
452    let Some(skin) = universe.visuals.skin(skin_id) else {
453        println!(
454            "[vtuber-joints-example] influence: skin_id={:?} missing in visuals",
455            skin_id
456        );
457        return;
458    };
459    let Some(cpu_h) = universe.render_assets.imported_mesh(mesh_key) else {
460        println!(
461            "[vtuber-joints-example] influence: mesh_key='{}' missing in RenderAssets (did meshes register?)",
462            mesh_key
463        );
464        return;
465    };
466    let Some(cpu) = universe.render_assets.cpu_mesh(cpu_h) else {
467        println!(
468            "[vtuber-joints-example] influence: mesh_key='{}' cpu mesh handle invalid",
469            mesh_key
470        );
471        return;
472    };
473    let (Some(joints0), Some(weights0)) = (cpu.joints0.as_ref(), cpu.weights0.as_ref()) else {
474        println!(
475            "[vtuber-joints-example] influence: mesh_key='{}' has no skin attributes",
476            mesh_key
477        );
478        return;
479    };
480
481    let joint_count = skin.joint_count();
482    let totals = compute_total_weight_per_joint(joints0, weights0, joint_count);
483
484    println!(
485        "[vtuber-joints-example] influence: mesh_key='{}' verts={} joints={}",
486        mesh_key,
487        cpu.vertices.len(),
488        joint_count
489    );
490
491    for &(node_index, joint_tx) in selected.iter() {
492        // Find the skin joint index that maps to this node.
493        let skin_joint_index = skin
494            .joint_node_indices
495            .iter()
496            .position(|&ni| ni == node_index);
497
498        let name = universe
499            .world
500            .get_component_record(joint_tx)
501            .map(|r| r.name.as_str())
502            .unwrap_or("<unknown>");
503
504        match skin_joint_index {
505            Some(j) => {
506                let total = totals.get(j).copied().unwrap_or(0.0);
507                println!(
508                    "  influence: name='{}' node_index={} skin_joint_index={} total_weight={:.3}",
509                    name, node_index, j, total
510                );
511            }
512            None => {
513                println!(
514                    "  influence: name='{}' node_index={} skin_joint_index=<none>",
515                    name, node_index
516                );
517            }
518        }
519    }
520}
521
522fn compute_total_weight_per_joint(
523    joints0: &[[u16; 4]],
524    weights0: &[[f32; 4]],
525    joint_count: usize,
526) -> Vec<f32> {
527    let mut totals = vec![0.0f32; joint_count];
528    if joint_count == 0 {
529        return totals;
530    }
531    for (jv, wv) in joints0.iter().zip(weights0.iter()) {
532        for lane in 0..4 {
533            let j = jv[lane] as usize;
534            if j >= joint_count {
535                continue;
536            }
537            let w = wv[lane];
538            if w > 0.0 {
539                totals[j] += w;
540            }
541        }
542    }
543    totals
544}
545
546fn select_body_prim0_influencers(
547    universe: &engine::Universe,
548    model_root: engine::ecs::ComponentId,
549    mesh_key: &str,
550    count: usize,
551    node_index_to_transform: &HashMap<usize, engine::ecs::ComponentId>,
552) -> Option<Vec<(usize, engine::ecs::ComponentId)>> {
553    if count == 0 {
554        return Some(Vec::new());
555    }
556
557    let renderable = find_renderable_by_mesh_key(&universe.world, model_root, mesh_key)?;
558    let skin_id = find_skin_id_for_renderable(&universe.world, renderable)?;
559    let skin = universe.visuals.skin(skin_id)?;
560
561    let cpu_h = universe.render_assets.imported_mesh(mesh_key)?;
562    let cpu = universe.render_assets.cpu_mesh(cpu_h)?;
563    let joints0 = cpu.joints0.as_ref()?;
564    let weights0 = cpu.weights0.as_ref()?;
565
566    let joint_count = skin.joint_count();
567    if joint_count == 0 {
568        return None;
569    }
570
571    let mut total_weight_per_joint: Vec<f32> = vec![0.0; joint_count];
572    for (jv, wv) in joints0.iter().zip(weights0.iter()) {
573        for lane in 0..4 {
574            let j = jv[lane] as usize;
575            if j >= joint_count {
576                continue;
577            }
578            let w = wv[lane];
579            if w > 0.0 {
580                total_weight_per_joint[j] += w;
581            }
582        }
583    }
584
585    let mut ranked: Vec<(usize, f32)> =
586        total_weight_per_joint.iter().copied().enumerate().collect();
587    ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
588
589    let min_total: f32 = 10.0;
590
591    println!(
592        "[vtuber-joints-example] auto joints for mesh_key='{}': verts={} joint_count={} min_total={}",
593        mesh_key,
594        cpu.vertices.len(),
595        joint_count,
596        min_total
597    );
598
599    let mut out: Vec<(usize, engine::ecs::ComponentId)> = Vec::with_capacity(count);
600    let mut seen: HashSet<engine::ecs::ComponentId> = HashSet::new();
601
602    for (joint_index, total) in ranked.into_iter() {
603        if out.len() >= count {
604            break;
605        }
606        if total < min_total {
607            continue;
608        }
609
610        let node_index = *skin.joint_node_indices.get(joint_index)?;
611        let Some(&joint_tx) = node_index_to_transform.get(&node_index) else {
612            continue;
613        };
614        if !seen.insert(joint_tx) {
615            continue;
616        }
617
618        let name = universe
619            .world
620            .get_component_record(joint_tx)
621            .map(|n| n.name.clone())
622            .unwrap_or_else(|| "<unknown>".to_string());
623
624        println!(
625            "  auto[{:<2}] joint_index={:<3} total_weight={:<10.3} node_index={:<3} name={} tx={:?}",
626            out.len(),
627            joint_index,
628            total,
629            node_index,
630            name,
631            joint_tx
632        );
633
634        out.push((node_index, joint_tx));
635    }
636
637    if out.is_empty() { None } else { Some(out) }
638}
639
640fn find_renderable_by_mesh_key(
641    world: &engine::ecs::World,
642    root: engine::ecs::ComponentId,
643    mesh_key: &str,
644) -> Option<engine::ecs::ComponentId> {
645    let mut stack = vec![root];
646    while let Some(node) = stack.pop() {
647        if let Some(m) = world.get_component_by_id_as::<MeshComponent>(node) {
648            if m.key == mesh_key {
649                return find_parent_renderable(world, node);
650            }
651        }
652        for &ch in world.children_of(node) {
653            stack.push(ch);
654        }
655    }
656    None
657}
658
659fn find_parent_renderable(
660    world: &engine::ecs::World,
661    mut cid: engine::ecs::ComponentId,
662) -> Option<engine::ecs::ComponentId> {
663    while let Some(parent) = world.parent_of(cid) {
664        if world
665            .get_component_by_id_as::<RenderableComponent>(parent)
666            .is_some()
667        {
668            return Some(parent);
669        }
670        cid = parent;
671    }
672    None
673}
674
675fn find_skin_id_for_renderable(
676    world: &engine::ecs::World,
677    renderable: engine::ecs::ComponentId,
678) -> Option<engine::graphics::SkinId> {
679    let mut stack = vec![renderable];
680    while let Some(node) = stack.pop() {
681        if let Some(sm) = world.get_component_by_id_as::<SkinnedMeshComponent>(node) {
682            if let Some(id) = sm.skin_id {
683                return Some(id);
684            }
685        }
686        for &ch in world.children_of(node) {
687            stack.push(ch);
688        }
689    }
690    None
691}
692
693fn collect_joint_transforms(
694    world: &engine::ecs::World,
695    skinned_mesh: &engine::ecs::system::SkinnedMeshSystem,
696    visuals: &engine::graphics::VisualWorld,
697    gltf_component: engine::ecs::ComponentId,
698    root: engine::ecs::ComponentId,
699) -> Vec<(usize, engine::ecs::ComponentId)> {
700    fn find_first_skin_id_in_subtree(
701        world: &engine::ecs::World,
702        root: engine::ecs::ComponentId,
703    ) -> Option<engine::graphics::SkinId> {
704        let mut stack = vec![root];
705        while let Some(node) = stack.pop() {
706            if let Some(sm) = world.get_component_by_id_as::<SkinnedMeshComponent>(node) {
707                if let Some(id) = sm.skin_id {
708                    return Some(id);
709                }
710            }
711            for &ch in world.children_of(node) {
712                stack.push(ch);
713            }
714        }
715        None
716    }
717
718    let Some(skin_id) = find_first_skin_id_in_subtree(world, root) else {
719        return Vec::new();
720    };
721    let Some(skin) = visuals.skin(skin_id) else {
722        return Vec::new();
723    };
724    let Some(joints) = skinned_mesh.instance_joints_for_skin(gltf_component, skin_id) else {
725        return Vec::new();
726    };
727
728    let joint_count = skin.joint_count().min(joints.len());
729    let mut out: Vec<(usize, engine::ecs::ComponentId)> = Vec::with_capacity(joint_count);
730    for i in 0..joint_count {
731        let Some(joint_tx) = joints[i] else {
732            continue;
733        };
734        let node_index = skin.joint_node_indices[i];
735        out.push((node_index, joint_tx));
736    }
737
738    out
739}
740
741fn select_joint_range(
742    joints: &[(usize, engine::ecs::ComponentId)],
743    offset: usize,
744    count: usize,
745) -> Vec<(usize, engine::ecs::ComponentId)> {
746    if joints.is_empty() || count == 0 {
747        return Vec::new();
748    }
749    let len = joints.len();
750    let start = offset % len;
751    let n = count.min(len);
752
753    (0..n).map(|i| joints[(start + i) % len]).collect()
754}
755
756fn quat_from_axis_angle(axis: [f32; 3], angle_rad: f32) -> [f32; 4] {
757    let [ax, ay, az] = axis;
758    let len2 = ax * ax + ay * ay + az * az;
759    if len2 <= 0.0 {
760        return [0.0, 0.0, 0.0, 1.0];
761    }
762    let inv_len = len2.sqrt().recip();
763    let (ax, ay, az) = (ax * inv_len, ay * inv_len, az * inv_len);
764
765    let half = 0.5 * angle_rad;
766    let (s, c) = half.sin_cos();
767    [ax * s, ay * s, az * s, c]
768}
769
770fn quat_conjugate(q: [f32; 4]) -> [f32; 4] {
771    [-q[0], -q[1], -q[2], q[3]]
772}
773
774fn quat_normalize(q: [f32; 4]) -> [f32; 4] {
775    let len2 = q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3];
776    if len2 <= 0.0 {
777        return [0.0, 0.0, 0.0, 1.0];
778    }
779    let inv = len2.sqrt().recip();
780    [q[0] * inv, q[1] * inv, q[2] * inv, q[3] * inv]
781}
782
783fn quat_rotate_vec3(q: [f32; 4], v: [f32; 3]) -> [f32; 3] {
784    // v' = q * (v,0) * conj(q)
785    let qv = [v[0], v[1], v[2], 0.0];
786    let t = quat_mul(q, qv);
787    let out = quat_mul(t, quat_conjugate(q));
788    [out[0], out[1], out[2]]
789}
790
791fn quat_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
792    let (ax, ay, az, aw) = (a[0], a[1], a[2], a[3]);
793    let (bx, by, bz, bw) = (b[0], b[1], b[2], b[3]);
794    [
795        aw * bx + ax * bw + ay * bz - az * by,
796        aw * by - ax * bz + ay * bw + az * bx,
797        aw * bz + ax * by - ay * bx + az * bw,
798        aw * bw - ax * bx - ay * by - az * bz,
799    ]
800}