Skip to main content

scena/assets/
environment_sidecar.rs

1use bytemuck::{Pod, Zeroable};
2
3use super::AssetPath;
4use crate::diagnostics::AssetError;
5
6pub const SIDECAR_FILE_SUFFIX: &str = ".prefilter.bin";
7const SIDECAR_MAGIC: [u8; 16] = *b"SCENA_ENV_PF_V2\0";
8const SIDECAR_VERSION: u32 = 2;
9const SIDE_CAR_FACE_COUNT: usize = 6;
10const RGBA_CHANNELS: usize = 4;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13#[repr(u32)]
14pub enum EnvironmentSidecarProfile {
15    InteractiveWebGl2 = 1,
16    Reference = 2,
17}
18
19impl EnvironmentSidecarProfile {
20    pub const fn name(self) -> &'static str {
21        match self {
22            Self::InteractiveWebGl2 => "InteractiveWebGl2",
23            Self::Reference => "Reference",
24        }
25    }
26
27    fn from_raw(value: u32) -> Option<Self> {
28        match value {
29            1 => Some(Self::InteractiveWebGl2),
30            2 => Some(Self::Reference),
31            _ => None,
32        }
33    }
34}
35
36#[repr(C)]
37#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
38pub struct EnvironmentSidecarHeader {
39    magic: [u8; 16],
40    version: u32,
41    profile: u32,
42    source_sha256: [u8; 32],
43    cubemap_resolution: u32,
44    mip_count: u32,
45    brdf_lut_size: u32,
46    reserved: u32,
47    diffuse_rgb: [f32; 3],
48    reserved_f32: f32,
49    specular_float_count: u64,
50    brdf_lut_float_count: u64,
51}
52
53impl EnvironmentSidecarHeader {
54    fn new(input: EnvironmentSidecarHeaderInput<'_>) -> Result<Self, AssetError> {
55        Ok(Self {
56            magic: SIDECAR_MAGIC,
57            version: SIDECAR_VERSION,
58            profile: input.profile as u32,
59            source_sha256: decode_sha256_hex(input.source_sha256_hex)?,
60            cubemap_resolution: input.cubemap_resolution,
61            mip_count: input.mip_count,
62            brdf_lut_size: input.brdf_lut_size,
63            reserved: 0,
64            diffuse_rgb: input.diffuse_rgb,
65            reserved_f32: 0.0,
66            specular_float_count: input.specular_float_count,
67            brdf_lut_float_count: input.brdf_lut_float_count,
68        })
69    }
70
71    pub fn profile(&self) -> Option<EnvironmentSidecarProfile> {
72        EnvironmentSidecarProfile::from_raw(self.profile)
73    }
74
75    pub fn profile_name(&self) -> &'static str {
76        self.profile()
77            .map(EnvironmentSidecarProfile::name)
78            .unwrap_or("unknown")
79    }
80
81    pub const fn cubemap_resolution(&self) -> u32 {
82        self.cubemap_resolution
83    }
84
85    pub const fn mip_count(&self) -> u32 {
86        self.mip_count
87    }
88
89    pub const fn brdf_lut_size(&self) -> u32 {
90        self.brdf_lut_size
91    }
92
93    pub const fn source_sha256_bytes(&self) -> [u8; 32] {
94        self.source_sha256
95    }
96
97    pub fn source_sha256_hex(&self) -> String {
98        encode_sha256_hex(&self.source_sha256)
99    }
100
101    fn validate(&self, path: &AssetPath) -> Result<(), AssetError> {
102        if self.magic != SIDECAR_MAGIC {
103            return Err(sidecar_parse_error(path, "invalid sidecar magic"));
104        }
105        if self.version != SIDECAR_VERSION {
106            return Err(sidecar_parse_error(
107                path,
108                format!("unsupported sidecar version {}", self.version),
109            ));
110        }
111        if self.profile().is_none() {
112            return Err(sidecar_parse_error(
113                path,
114                format!("unsupported sidecar profile {}", self.profile),
115            ));
116        }
117        if self.cubemap_resolution == 0 || self.mip_count == 0 || self.brdf_lut_size == 0 {
118            return Err(sidecar_parse_error(
119                path,
120                "sidecar dimensions must be non-zero",
121            ));
122        }
123        if self.specular_float_count == 0 || self.brdf_lut_float_count == 0 {
124            return Err(sidecar_parse_error(
125                path,
126                "sidecar payload counts must be non-zero",
127            ));
128        }
129        Ok(())
130    }
131}
132
133struct EnvironmentSidecarHeaderInput<'a> {
134    profile: EnvironmentSidecarProfile,
135    source_sha256_hex: &'a str,
136    cubemap_resolution: u32,
137    mip_count: u32,
138    brdf_lut_size: u32,
139    diffuse_rgb: [f32; 3],
140    specular_float_count: u64,
141    brdf_lut_float_count: u64,
142}
143
144#[derive(Debug, Clone, PartialEq)]
145pub struct EnvironmentPrefilterSidecar {
146    header: EnvironmentSidecarHeader,
147    mips: Vec<[Vec<f32>; 6]>,
148    brdf_lut: Vec<f32>,
149}
150
151impl EnvironmentPrefilterSidecar {
152    pub fn new(
153        profile: EnvironmentSidecarProfile,
154        source_sha256_hex: &str,
155        cubemap_resolution: u32,
156        mips: Vec<[Vec<f32>; 6]>,
157        brdf_lut: Vec<f32>,
158        brdf_lut_size: u32,
159        diffuse_rgb: [f32; 3],
160    ) -> Result<Self, AssetError> {
161        let specular_float_count = mips
162            .iter()
163            .flat_map(|mip| mip.iter())
164            .map(Vec::len)
165            .sum::<usize>();
166        let header = EnvironmentSidecarHeader::new(EnvironmentSidecarHeaderInput {
167            profile,
168            source_sha256_hex,
169            cubemap_resolution,
170            mip_count: mips.len() as u32,
171            brdf_lut_size,
172            diffuse_rgb,
173            specular_float_count: specular_float_count as u64,
174            brdf_lut_float_count: brdf_lut.len() as u64,
175        })?;
176        Ok(Self {
177            header,
178            mips,
179            brdf_lut,
180        })
181    }
182
183    pub const fn header(&self) -> &EnvironmentSidecarHeader {
184        &self.header
185    }
186
187    pub fn profile(&self) -> EnvironmentSidecarProfile {
188        self.header
189            .profile()
190            .expect("EnvironmentPrefilterSidecar always carries a valid profile")
191    }
192
193    pub fn source_sha256_hex(&self) -> String {
194        self.header.source_sha256_hex()
195    }
196
197    pub const fn cubemap_resolution(&self) -> u32 {
198        self.header.cubemap_resolution
199    }
200
201    pub const fn mip_count(&self) -> u32 {
202        self.header.mip_count
203    }
204
205    pub const fn brdf_lut_size(&self) -> u32 {
206        self.header.brdf_lut_size
207    }
208
209    pub const fn diffuse_rgb(&self) -> [f32; 3] {
210        self.header.diffuse_rgb
211    }
212
213    pub fn mips(&self) -> &[[Vec<f32>; 6]] {
214        &self.mips
215    }
216
217    pub fn brdf_lut(&self) -> &[f32] {
218        &self.brdf_lut
219    }
220
221    pub fn to_bytes(&self) -> Vec<u8> {
222        let mut bytes = Vec::with_capacity(
223            std::mem::size_of::<EnvironmentSidecarHeader>()
224                + (self.header.specular_float_count as usize + self.brdf_lut.len())
225                    * std::mem::size_of::<f32>(),
226        );
227        bytes.extend_from_slice(bytemuck::bytes_of(&self.header));
228        for mip in &self.mips {
229            for face in mip {
230                bytes.extend_from_slice(bytemuck::cast_slice(face));
231            }
232        }
233        bytes.extend_from_slice(bytemuck::cast_slice(&self.brdf_lut));
234        bytes
235    }
236
237    pub fn parse(path: impl Into<AssetPath>, bytes: &[u8]) -> Result<Self, AssetError> {
238        let path = path.into();
239        let header_size = std::mem::size_of::<EnvironmentSidecarHeader>();
240        if bytes.len() < header_size {
241            return Err(sidecar_parse_error(
242                &path,
243                format!(
244                    "sidecar is {} byte(s), smaller than {header_size}-byte header",
245                    bytes.len()
246                ),
247            ));
248        }
249        let header =
250            bytemuck::pod_read_unaligned::<EnvironmentSidecarHeader>(&bytes[..header_size]);
251        header.validate(&path)?;
252        let payload = &bytes[header_size..];
253        if !payload.len().is_multiple_of(std::mem::size_of::<f32>()) {
254            return Err(sidecar_parse_error(
255                &path,
256                "sidecar payload length is not f32-aligned",
257            ));
258        }
259        let floats = payload
260            .chunks_exact(std::mem::size_of::<f32>())
261            .map(|chunk| f32::from_le_bytes(chunk.try_into().expect("chunk size is 4")))
262            .collect::<Vec<_>>();
263        let expected_float_count =
264            (header.specular_float_count + header.brdf_lut_float_count) as usize;
265        if floats.len() != expected_float_count {
266            return Err(sidecar_parse_error(
267                &path,
268                format!(
269                    "sidecar payload contains {} f32 value(s), expected {expected_float_count}",
270                    floats.len()
271                ),
272            ));
273        }
274        let mut cursor = 0usize;
275        let mut mips = Vec::with_capacity(header.mip_count as usize);
276        for mip in 0..header.mip_count {
277            let mip_resolution = (header.cubemap_resolution >> mip).max(1);
278            let face_float_count = (mip_resolution as usize).pow(2) * RGBA_CHANNELS;
279            let mut faces: [Vec<f32>; SIDE_CAR_FACE_COUNT] = std::array::from_fn(|_| Vec::new());
280            for face in &mut faces {
281                let end = cursor.saturating_add(face_float_count);
282                let Some(slice) = floats.get(cursor..end) else {
283                    return Err(sidecar_parse_error(
284                        &path,
285                        "sidecar mip chain ended before all faces were decoded",
286                    ));
287                };
288                *face = slice.to_vec();
289                cursor = end;
290            }
291            mips.push(faces);
292        }
293        if cursor != header.specular_float_count as usize {
294            return Err(sidecar_parse_error(
295                &path,
296                "sidecar specular mip count does not match header",
297            ));
298        }
299        let brdf_lut = floats[cursor..].to_vec();
300        let expected_brdf_len = (header.brdf_lut_size as usize).pow(2) * 2;
301        if brdf_lut.len() != expected_brdf_len {
302            return Err(sidecar_parse_error(
303                &path,
304                format!(
305                    "BRDF LUT has {} f32 value(s), expected {expected_brdf_len}",
306                    brdf_lut.len()
307                ),
308            ));
309        }
310        Ok(Self {
311            header,
312            mips,
313            brdf_lut,
314        })
315    }
316}
317
318pub fn sidecar_path_for_environment(path: &AssetPath) -> AssetPath {
319    AssetPath::from(format!("{}{}", path.as_str(), SIDECAR_FILE_SUFFIX))
320}
321
322pub fn parse_sidecar_header(
323    path: impl Into<AssetPath>,
324    bytes: &[u8],
325) -> Result<EnvironmentSidecarHeader, AssetError> {
326    let path = path.into();
327    let header_size = std::mem::size_of::<EnvironmentSidecarHeader>();
328    if bytes.len() < header_size {
329        return Err(sidecar_parse_error(
330            &path,
331            format!(
332                "sidecar is {} byte(s), smaller than {header_size}-byte header",
333                bytes.len()
334            ),
335        ));
336    }
337    let header = bytemuck::pod_read_unaligned::<EnvironmentSidecarHeader>(&bytes[..header_size]);
338    header.validate(&path)?;
339    Ok(header)
340}
341
342pub fn sha256_hex(bytes: &[u8]) -> String {
343    use sha2::{Digest, Sha256};
344
345    let mut hasher = Sha256::new();
346    hasher.update(bytes);
347    encode_sha256_hex(&hasher.finalize().into())
348}
349
350fn decode_sha256_hex(value: &str) -> Result<[u8; 32], AssetError> {
351    if value.len() != 64 {
352        return Err(AssetError::Parse {
353            path: "environment-sidecar".to_string(),
354            reason: format!("SHA-256 must be 64 hex characters, got {}", value.len()),
355        });
356    }
357    let mut out = [0_u8; 32];
358    for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
359        out[index] = decode_hex_byte(pair[0], pair[1]).ok_or_else(|| AssetError::Parse {
360            path: "environment-sidecar".to_string(),
361            reason: "SHA-256 contains non-hex characters".to_string(),
362        })?;
363    }
364    Ok(out)
365}
366
367fn encode_sha256_hex(bytes: &[u8; 32]) -> String {
368    const HEX: &[u8; 16] = b"0123456789abcdef";
369    let mut out = String::with_capacity(64);
370    for byte in bytes {
371        out.push(HEX[(byte >> 4) as usize] as char);
372        out.push(HEX[(byte & 0x0f) as usize] as char);
373    }
374    out
375}
376
377fn decode_hex_byte(high: u8, low: u8) -> Option<u8> {
378    Some(hex_nibble(high)? << 4 | hex_nibble(low)?)
379}
380
381fn hex_nibble(byte: u8) -> Option<u8> {
382    match byte {
383        b'0'..=b'9' => Some(byte - b'0'),
384        b'a'..=b'f' => Some(byte - b'a' + 10),
385        b'A'..=b'F' => Some(byte - b'A' + 10),
386        _ => None,
387    }
388}
389
390fn sidecar_parse_error(path: &AssetPath, reason: impl Into<String>) -> AssetError {
391    AssetError::Parse {
392        path: path.as_str().to_string(),
393        reason: reason.into(),
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn sidecar_round_trip_preserves_header_and_payload() {
403        let mips = vec![
404            std::array::from_fn(|face| vec![face as f32; 4 * 4 * 4]),
405            std::array::from_fn(|face| vec![10.0 + face as f32; 2 * 2 * 4]),
406        ];
407        let source_sha = "ae94a965734e6306216feb48d6dd7154b1dbc484a605200bf13cb9ae23799b7b";
408        let sidecar = EnvironmentPrefilterSidecar::new(
409            EnvironmentSidecarProfile::InteractiveWebGl2,
410            source_sha,
411            4,
412            mips,
413            vec![0.25; 8],
414            2,
415            [0.1, 0.2, 0.3],
416        )
417        .expect("sidecar builds");
418
419        let parsed =
420            EnvironmentPrefilterSidecar::parse("test.hdr.prefilter.bin", &sidecar.to_bytes())
421                .expect("sidecar parses");
422
423        assert_eq!(
424            parsed.profile(),
425            EnvironmentSidecarProfile::InteractiveWebGl2
426        );
427        assert_eq!(parsed.source_sha256_hex(), source_sha);
428        assert_eq!(parsed.cubemap_resolution(), 4);
429        assert_eq!(parsed.mip_count(), 2);
430        assert_eq!(parsed.brdf_lut_size(), 2);
431        assert_eq!(parsed.diffuse_rgb(), [0.1, 0.2, 0.3]);
432        assert_eq!(parsed.mips()[1][3], vec![13.0; 16]);
433        assert_eq!(parsed.brdf_lut(), &[0.25; 8]);
434    }
435
436    #[test]
437    fn legacy_v1_sidecar_is_rejected_after_khronos_baker_change() {
438        let mips = vec![std::array::from_fn(|face| vec![face as f32; 4 * 4 * 4])];
439        let source_sha = "ae94a965734e6306216feb48d6dd7154b1dbc484a605200bf13cb9ae23799b7b";
440        let sidecar = EnvironmentPrefilterSidecar::new(
441            EnvironmentSidecarProfile::InteractiveWebGl2,
442            source_sha,
443            4,
444            mips,
445            vec![0.25; 8],
446            2,
447            [0.1, 0.2, 0.3],
448        )
449        .expect("sidecar builds");
450        let mut legacy_bytes = sidecar.to_bytes();
451        legacy_bytes[..16].copy_from_slice(b"SCENA_ENV_PF_V1\0");
452        legacy_bytes[16..20].copy_from_slice(&1_u32.to_le_bytes());
453
454        let error = EnvironmentPrefilterSidecar::parse("legacy.hdr.prefilter.bin", &legacy_bytes)
455            .expect_err("legacy sidecar must not bypass the current baker");
456
457        let AssetError::Parse { reason, .. } = error else {
458            panic!("legacy sidecar should fail with a parse error");
459        };
460        assert!(
461            reason.contains("invalid sidecar magic")
462                || reason.contains("unsupported sidecar version"),
463            "legacy sidecar rejection must be explicit, got {reason}"
464        );
465    }
466}