Skip to main content

unlit3d_examples/scenes/
morphed.rs

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