Skip to main content

concinnity_core/bake/
payload.rs

1//! Baking an asset's payload from its typed value.
2//!
3//! Every bake here is pure computation over the value's fields: a generator's
4//! geometry, an IBL convolution, the built-in face's glyph atlas, a material's
5//! clamped parameters. An asset whose payload needs a file read, an image
6//! decode, or a shader compiler is refused with an error naming the cook
7//! module, which is where the importers live.
8//!
9//! Where a generator argument is optional, the fallback is the one the
10//! authored path applies to the same absent argument, so a world declared
11//! either way bakes the same bytes.
12
13use alloc::string::{String, ToString};
14use alloc::vec::Vec;
15
16use crate::bake::environment_map::RowScheduler;
17use crate::bake::environment_map::source::{HdrImage, bake_payload, generate_sky_equirect};
18use crate::bake::environment_map::stars::generate_stars_equirect;
19use crate::bake::mesh::{finish_mesh_payload, vertices_from_data};
20use crate::components::{EnvironmentMap, Font, Material, Mesh, ProceduralMesh, validate};
21use crate::geometry::{
22    Vert, build_box, build_cylinder, build_extrude, build_plane, build_room_geometry, build_skybox,
23    build_sphere, build_terrain, water_grid,
24};
25
26// Fallbacks for the generator arguments a `ProceduralMesh` leaves unset. Each
27// matches what the authored path uses for the same absent argument.
28const BOX_HALF_EXTENTS: [f32; 3] = [0.5, 0.5, 0.5];
29const CYLINDER_RADIUS: f32 = 0.5;
30const CYLINDER_HEIGHT: f32 = 1.0;
31const CYLINDER_SEGMENTS: u32 = 16;
32const SPHERE_RADIUS: f32 = 1.0;
33const SPHERE_RINGS: u32 = 12;
34const SPHERE_SEGMENTS: u32 = 16;
35const TERRAIN_SUBDIVISIONS: u32 = 64;
36const TERRAIN_AMPLITUDE: f32 = 4.0;
37const SKYBOX_SIZE: f32 = 490.0;
38const EXTRUDE_HEIGHT: f32 = 1.0;
39const EXTRUDE_CORNER_RADIUS: f32 = 0.0;
40const EXTRUDE_CORNER_SEGMENTS: u32 = 8;
41const WATER_SUBDIVISIONS: u32 = 64;
42
43/// Bake a `ProceduralMesh`'s geometry into its blob payload.
44pub fn procedural_mesh(mesh: &ProceduralMesh) -> Result<Vec<u8>, String> {
45    let (vertices, indices): (Vec<Vert>, Vec<u16>) = match mesh.generator.as_str() {
46        "room" => build_room_geometry(mesh.half_width, mesh.half_depth, 0.0, mesh.ceiling_height),
47        "box" => build_box(mesh.half_extents.unwrap_or(BOX_HALF_EXTENTS)),
48        "cylinder" => build_cylinder(
49            mesh.radius.unwrap_or(CYLINDER_RADIUS),
50            mesh.height.unwrap_or(CYLINDER_HEIGHT),
51            mesh.segments.unwrap_or(CYLINDER_SEGMENTS),
52        ),
53        "plane" => build_plane(mesh.half_width, mesh.half_depth),
54        "sphere" => build_sphere(
55            mesh.radius.unwrap_or(SPHERE_RADIUS),
56            mesh.rings.unwrap_or(SPHERE_RINGS),
57            mesh.segments.unwrap_or(SPHERE_SEGMENTS),
58        )?,
59        "terrain" => build_terrain(
60            mesh.half_width,
61            mesh.half_depth,
62            mesh.subdivisions.unwrap_or(TERRAIN_SUBDIVISIONS),
63            mesh.amplitude.unwrap_or(TERRAIN_AMPLITUDE),
64        )?,
65        "skybox" => build_skybox(mesh.size.unwrap_or(SKYBOX_SIZE)),
66        "extrude" => {
67            let profile = mesh
68                .profile
69                .as_deref()
70                .ok_or("the `extrude` generator needs a `profile` of [x, z] points")?;
71            build_extrude(
72                profile,
73                mesh.height.unwrap_or(EXTRUDE_HEIGHT),
74                mesh.corner_radius.unwrap_or(EXTRUDE_CORNER_RADIUS),
75                mesh.corner_segments.unwrap_or(EXTRUDE_CORNER_SEGMENTS),
76            )?
77        }
78        "water_grid" => water_grid::build_water_grid(
79            mesh.half_width,
80            mesh.half_depth,
81            mesh.subdivisions.unwrap_or(WATER_SUBDIVISIONS),
82        )?,
83        // The heightfield generator reads a greyscale image, which is an
84        // importer's job.
85        "heightfield" => {
86            return Err(
87                "the `heightfield` generator reads a source image; compile it with the \
88                 cook module"
89                    .to_string(),
90            );
91        }
92        "" => return Err("a ProceduralMesh needs a `generator`".to_string()),
93        other => return Err(alloc::format!("unknown mesh generator '{other}'")),
94    };
95    finish_mesh_payload(vertices, indices, mesh.lod_levels, &mesh.lod_distances)
96}
97
98/// Bake a raw `Mesh`'s vertices and indices into its blob payload. Normals
99/// and tangents are derived here; a `source` naming a file needs an importer.
100pub fn mesh(mesh: &Mesh) -> Result<Vec<u8>, String> {
101    if !mesh.source.is_empty() {
102        return Err(
103            "a Mesh with a `source` reads a model file; compile it with the cook module"
104                .to_string(),
105        );
106    }
107    if mesh.vertices.is_empty() || mesh.indices.is_empty() {
108        return Err("a Mesh needs `vertices` and `indices`".to_string());
109    }
110    if !mesh.indices.len().is_multiple_of(3) {
111        return Err(alloc::format!(
112            "a Mesh's indices come in triangles; {} is not a multiple of 3",
113            mesh.indices.len()
114        ));
115    }
116    let vertices = vertices_from_data(&mesh.vertices, &mesh.indices)?;
117    finish_mesh_payload(
118        vertices,
119        mesh.indices.clone(),
120        mesh.lod_levels,
121        &mesh.lod_distances,
122    )
123}
124
125/// Bake an `EnvironmentMap`'s IBL cubemaps into its blob payload, spreading
126/// each convolution's rows over `rows`.
127pub fn environment_map<S: RowScheduler>(map: &EnvironmentMap, rows: &S) -> Result<Vec<u8>, String> {
128    if !map.source.is_empty() {
129        return Err(
130            "an EnvironmentMap with a `source` reads a panorama file; compile it with the \
131             cook module"
132                .to_string(),
133        );
134    }
135    let generate: fn() -> HdrImage = match map.generator.as_str() {
136        "sky" => generate_sky_equirect,
137        "stars" => generate_stars_equirect,
138        "" => return Err("an EnvironmentMap needs a `source` or a `generator`".to_string()),
139        other => return Err(alloc::format!("unknown EnvironmentMap generator '{other}'")),
140    };
141    crate::bake::environment_map::check_sizes(map)?;
142    Ok(bake_payload(
143        &generate(),
144        map.prefilter_face_size,
145        map.irradiance_face_size,
146        map.prefilter_samples,
147        map.prefilter_clamp,
148        rows,
149    ))
150}
151
152/// Rasterise a `Font` into its glyph-atlas payload.
153pub fn font(font: &Font) -> Result<Vec<u8>, String> {
154    if !font.path.is_empty() {
155        return Err(
156            "a Font with a `path` reads a TTF file; compile it with the cook module".to_string(),
157        );
158    }
159    crate::bake::font::compile(
160        crate::bake::font::BUILTIN_FONT_BYTES,
161        font.size_px,
162        "<built-in>",
163    )
164}
165
166/// Bake a `Material` into the runtime bytes its resource record carries. A
167/// material has no blob payload: the clamped parameters are the whole of it.
168pub fn material(material: Material) -> Result<Vec<u8>, String> {
169    postcard::to_allocvec(&validate::material(material))
170        .map_err(|e| alloc::format!("Material serialise: {e}"))
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::bake::environment_map::Serial;
177    use crate::gfx::mesh_payload;
178
179    fn mesh(generator: &str) -> ProceduralMesh {
180        ProceduralMesh {
181            generator: generator.into(),
182            ..Default::default()
183        }
184    }
185
186    // Every generator the builder reaches produces a payload the runtime's own
187    // reader accepts; the geometry itself is tested where it is generated.
188    #[test]
189    fn every_reachable_generator_bakes_a_readable_payload() {
190        let mut extrude = mesh("extrude");
191        extrude.profile = Some(alloc::vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]);
192        for m in [
193            mesh("room"),
194            mesh("box"),
195            mesh("cylinder"),
196            mesh("plane"),
197            mesh("sphere"),
198            mesh("terrain"),
199            mesh("skybox"),
200            mesh("water_grid"),
201            extrude,
202        ] {
203            let payload = procedural_mesh(&m).unwrap_or_else(|e| panic!("{}: {e}", m.generator));
204            let read = mesh_payload::deserialise(&payload)
205                .unwrap_or_else(|e| panic!("{}: {e}", m.generator));
206            assert!(!read.0.is_empty(), "{} has vertices", m.generator);
207        }
208    }
209
210    // The unset optional arguments fall back to the same values the authored
211    // path uses, so a box with no half-extents is the same unit cube either
212    // way.
213    #[test]
214    fn an_unset_optional_argument_falls_back_to_the_authored_default() {
215        let payload = procedural_mesh(&mesh("box")).expect("a box bakes");
216        let (vertices, indices) = build_box(BOX_HALF_EXTENTS);
217        let expected = finish_mesh_payload(vertices, indices, 1, &[]).expect("the same box packs");
218        assert_eq!(payload, expected);
219    }
220
221    #[test]
222    fn a_generator_that_needs_an_importer_says_so() {
223        for (m, needle) in [
224            (mesh("heightfield"), "source image"),
225            (mesh(""), "needs a `generator`"),
226            (mesh("nonesuch"), "unknown mesh generator"),
227            (mesh("extrude"), "needs a `profile`"),
228        ] {
229            let err = procedural_mesh(&m).expect_err("not bakeable");
230            assert!(err.contains(needle), "{err}");
231        }
232    }
233
234    fn triangle() -> Mesh {
235        let vd = |pos: [f32; 3], uv: [f32; 2]| crate::components::VertexData {
236            pos,
237            color: [1.0; 3],
238            uv,
239        };
240        Mesh {
241            vertices: alloc::vec![
242                vd([0.0, 0.0, 0.0], [0.0, 0.0]),
243                vd([1.0, 0.0, 0.0], [1.0, 0.0]),
244                vd([0.0, 1.0, 0.0], [0.0, 1.0]),
245            ],
246            indices: alloc::vec![0, 1, 2],
247            ..Default::default()
248        }
249    }
250
251    #[test]
252    fn raw_geometry_bakes_with_derived_normals() {
253        let payload = super::mesh(&triangle()).expect("a triangle bakes");
254        let (verts, indices) = mesh_payload::deserialise(&payload).expect("the payload reads back");
255        assert_eq!(indices, alloc::vec![0, 1, 2]);
256        assert!(verts.iter().all(|v| (v.normal[2] - 1.0).abs() < 1e-5));
257    }
258
259    #[test]
260    fn raw_geometry_reports_what_it_cannot_bake() {
261        let sourced = Mesh {
262            source: "chair.glb".into(),
263            ..Default::default()
264        };
265        let err = super::mesh(&sourced).expect_err("a file source");
266        assert!(err.contains("cook module"), "{err}");
267
268        let err = super::mesh(&Mesh::default()).expect_err("nothing to bake");
269        assert!(err.contains("needs `vertices` and `indices`"), "{err}");
270
271        let mut ragged = triangle();
272        ragged.indices.push(0);
273        let err = super::mesh(&ragged).expect_err("a partial triangle");
274        assert!(err.contains("multiple of 3"), "{err}");
275
276        let mut past = triangle();
277        past.indices[2] = 7;
278        let err = super::mesh(&past).expect_err("an index past the list");
279        assert!(err.contains("indexes past"), "{err}");
280    }
281
282    #[test]
283    fn the_sky_generator_bakes_an_environment_payload() {
284        let map = EnvironmentMap {
285            generator: "sky".into(),
286            prefilter_face_size: 16,
287            irradiance_face_size: 8,
288            prefilter_samples: 4,
289            ..Default::default()
290        };
291        let payload = environment_map(&map, &Serial).expect("the sky bakes");
292        let view =
293            crate::bake::environment_map::deserialise(&payload).expect("the payload reads back");
294        assert_eq!(view.irradiance_face, 8);
295        assert_eq!(view.prefilter_face, 16);
296    }
297
298    #[test]
299    fn an_environment_map_reports_what_it_cannot_bake() {
300        let with_source = EnvironmentMap {
301            source: "studio.hdr".into(),
302            ..Default::default()
303        };
304        let err = environment_map(&with_source, &Serial).expect_err("a file source");
305        assert!(err.contains("cook module"), "{err}");
306
307        let blank = EnvironmentMap::default();
308        let err = environment_map(&blank, &Serial).expect_err("nothing to bake");
309        assert!(err.contains("needs a `source` or a `generator`"), "{err}");
310
311        let unknown = EnvironmentMap {
312            generator: "swamp".into(),
313            ..Default::default()
314        };
315        let err = environment_map(&unknown, &Serial).expect_err("unknown generator");
316        assert!(err.contains("unknown EnvironmentMap generator"), "{err}");
317
318        let oversized = EnvironmentMap {
319            generator: "sky".into(),
320            prefilter_face_size: 3,
321            ..Default::default()
322        };
323        let err = environment_map(&oversized, &Serial).expect_err("out of range");
324        assert!(err.contains("prefilter_face_size"), "{err}");
325    }
326
327    #[test]
328    fn the_builtin_face_rasterises_and_a_file_one_does_not() {
329        let payload = font(&Font {
330            size_px: 12,
331            ..Default::default()
332        })
333        .expect("the built-in face bakes");
334        let (_, _, _, size_px, _, metrics) =
335            crate::bake::font::deserialise(&payload).expect("the atlas reads back");
336        assert_eq!(size_px, 12);
337        assert!(!metrics.is_empty());
338
339        let err = font(&Font {
340            path: "assets/face.ttf".into(),
341            ..Default::default()
342        })
343        .expect_err("a file face");
344        assert!(err.contains("cook module"), "{err}");
345    }
346
347    // A material's bytes are its clamped parameters, the same clamps the
348    // authored path applies on its way into the blob.
349    #[test]
350    fn a_material_bakes_its_clamped_parameters() {
351        let bytes = material(Material {
352            roughness: 4.0,
353            see_through: true,
354            ..Default::default()
355        })
356        .expect("a material bakes");
357        let read: Material = postcard::from_bytes(&bytes).expect("the bytes read back");
358        assert_eq!(read.roughness, 1.0);
359        assert!(read.transparent, "see-through implies transparent");
360    }
361}