Skip to main content

unlit3d_examples/scenes/
skinned.rs

1//! The skinned-cube scene: a mesh bent by two joints across six frames.
2//!
3//! Ported from the `ecs_skinned_cube_matches_snapshots` test. A skinned cube
4//! is drawn over several frames while its pose animates: the joint matrices
5//! live in a [`SkinPose`] component the CPU updates, and each frame is stored
6//! as its own snapshot.
7
8use super::{
9    BendSkin, SEQUENCE_STEP, SceneControl, SceneDef, SceneOptions, TEST_SIZE, default_options,
10};
11use unlit_ecs::{Bundle, DynamicBundle, bundle};
12use unlit3d::prelude::*;
13
14/// The frames rendered, one snapshot each.
15const FRAMES: usize = 6;
16
17/// Where the camera sits, close enough that a deformation moves many pixels.
18const CAMERA_EYE: glam::Vec3 = glam::Vec3::new(0.9, 1.1, 2.6);
19
20/// The scene: a cube bending frame by frame, each stored as its own snapshot.
21pub static SCENE: SceneDef = SceneDef {
22    id: "skinned",
23    title: "Skinned cube",
24    description: "a cube bent by a two-joint skin over six frames",
25    size: TEST_SIZE,
26    baseline: Some(TEST_SIZE),
27    clear: None,
28    frames: FRAMES as u32,
29    step_seconds: Some(SEQUENCE_STEP),
30    samples: 1,
31    depth: true,
32    ui: false,
33    reproducible_ui: false,
34    build,
35};
36
37/// The scene's one visible object: a cube bent by its own pose.
38///
39/// The mesh and the entity that carries the joint matrices are what a spawn
40/// has to name, so they are the fields; the transform and the tint belong to
41/// the scene, so they are the bundle's own.
42struct SkinnedCube {
43    mesh: GpuMesh,
44    pipeline: GpuRenderPipeline,
45    pose: Entity,
46}
47
48impl Bundle for SkinnedCube {
49    fn into_bundle(self) -> DynamicBundle {
50        bundle![
51            Transform {
52                scale: glam::Vec3::splat(0.9),
53                ..Default::default()
54            },
55            InstanceColor::new(glam::Vec4::new(1.0, 0.85, 0.6, 1.0)),
56            self.mesh,
57            self.pipeline,
58            SkinBinding { pose: self.pose },
59        ]
60    }
61}
62
63/// Build the skinned-cube scene's world content.
64fn build(
65    world: &mut World,
66    context: RenderContext,
67    pass: Entity,
68    _ui: Option<Entity>,
69    size: (u32, u32),
70    _options: SceneOptions,
71) -> SceneControl {
72    let mut source = MeshSource::new(&world.root_scope(), context);
73    source.register_lit_family(&world.root_scope());
74    // The variant that reads the joint stream: its position buffer is wider by
75    // the joint pair, so a stream packed for the plain cube would misread.
76    let key = LitPipelineKey::new(default_options());
77    let source_entity = world
78        .scope_mut(pass)
79        .expect("the pass is alive")
80        .spawn(bundle![Source::new(source)]);
81
82    let (positions, uvs, colors, indices) = super::cube();
83    let skin = BendSkin::new(&positions, bend_angle(0));
84    let mesh = super::with_mesh_source(world, source_entity, |source, world| {
85        source.allocate_lit_mesh(
86            world,
87            &key.options,
88            LitMeshDesc {
89                positions: &positions,
90                uvs: Some(&uvs),
91                colors: Some(&colors),
92                normals: None,
93                indices: Some(&indices),
94                joints: Some(&skin.joints),
95                weights: Some(&skin.weights),
96                morph_deltas: None,
97            },
98        )
99    });
100
101    let camera_entity = super::spawn_in(world, source_entity, bundle![camera(size)]);
102    // The pose is an entity of its own: the mesh names it rather than owning
103    // it, so animating the skeleton is one component write and nothing on the
104    // mesh changes.
105    let pose_entity = super::spawn_in(world, source_entity, bundle![skin.pose()]);
106    super::spawn_in(
107        world,
108        source_entity,
109        SkinnedCube {
110            mesh,
111            pipeline: GpuRenderPipeline::new(key),
112            pose: pose_entity,
113        },
114    );
115
116    let mut skin = skin;
117    SceneControl {
118        advance: Box::new(move |world, frame, _delta, frame_size| {
119            let frame = frame as usize % FRAMES;
120            // The pose is a component write, not a mesh re-upload and not a
121            // GPU call: the source packs and uploads it once per frame.
122            skin.set_angle(bend_angle(frame));
123            *world
124                .scope(source_entity)
125                .expect("the source is alive")
126                .query_one::<&mut SkinPose>(pose_entity)
127                .expect("the pose entity carries a `SkinPose`")
128                .get() = skin.pose();
129            // The camera the whole sequence shares, re-aimed at the frame's
130            // aspect so a resized window does not stretch the cube.
131            super::aim_camera(
132                world,
133                source_entity,
134                camera_entity,
135                CAMERA_EYE,
136                CAMERA_TARGET,
137                frame_size,
138            );
139        }),
140        snapshot: Box::new(|frame| {
141            (frame < FRAMES as u32).then(|| format!("skinned/frame_{frame:02}.webp"))
142        }),
143    }
144}
145
146/// The point the camera looks at.
147const CAMERA_TARGET: glam::Vec3 = glam::Vec3::new(0.0, 0.2, 0.0);
148
149/// The camera the whole sequence shares, so a frame's difference is the pose's.
150fn camera(size: (u32, u32)) -> Camera {
151    super::camera_looking_at(CAMERA_EYE, CAMERA_TARGET, size)
152}
153
154/// The angle the bending joint holds in `frame`.
155///
156/// Frame zero is the rest pose, which gives the sequence a reference every
157/// later frame departs from; the sign flips halfway so the cube bends both
158/// ways instead of swinging through one arc.
159fn bend_angle(frame: usize) -> f32 {
160    let half = FRAMES / 2;
161    let step = if frame < half {
162        frame as isize
163    } else {
164        half as isize - (frame - half) as isize
165    };
166    step as f32 * 0.42
167}