Skip to main content

khora_io/asset/decoders/
texture.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Texture decoder: image bytes → `CpuTexture` (via the `image` crate).
16//!
17//! Auto-registered via [`inventory::submit!`] under the canonical
18//! `"texture"` slot. Single canonical implementation — no swap needed.
19//!
20//! The decoder reports the pixel **layout** only. Whether the values are to be
21//! read as sRGB or linear is the *role* of the material slot referencing them,
22//! supplied downstream as a
23//! [`TextureColorSpace`](khora_core::renderer::api::util::TextureColorSpace) —
24//! the same PNG is an sRGB albedo map or a linear normal map depending on the
25//! slot. Float sources (Radiance `.hdr`, OpenEXR) keep their high dynamic range
26//! instead of being flattened to 8-bit, which is what makes an authored
27//! environment map usable by the IBL bake.
28
29use anyhow::{Context, Result};
30use image::ColorType;
31use khora_core::{
32    math::Extent3D,
33    renderer::api::{
34        resource::{CpuTexture, TextureDimension, TextureUsage},
35        util::{f32_to_f16_bits, SampleCount, TextureFormat},
36    },
37};
38
39use crate::asset::{AssetDecoder, DecoderRegistration};
40
41/// Decodes common image formats (PNG, JPEG, HDR, EXR, …) into a `CpuTexture`.
42#[derive(Clone, Default)]
43pub struct TextureDecoder;
44
45impl AssetDecoder<CpuTexture> for TextureDecoder {
46    fn load(
47        &self,
48        bytes: &[u8],
49    ) -> Result<CpuTexture, Box<dyn std::error::Error + Send + Sync + 'static>> {
50        let img = image::load_from_memory(bytes).context("Failed to decode image from memory")?;
51
52        // Float sources carry HDR range that 8-bit cannot hold. They upload as
53        // `Rgba16Float` rather than `Rgba32Float`: half is filterable on every
54        // backend, whereas 32-bit float filtering is an optional wgpu feature
55        // the environment bake cannot assume.
56        let (pixels, format) = if matches!(img.color(), ColorType::Rgb32F | ColorType::Rgba32F) {
57            let float_img = img.to_rgba32f();
58            let halves: Vec<u8> = float_img
59                .as_raw()
60                .iter()
61                .flat_map(|&c| f32_to_f16_bits(c).to_le_bytes())
62                .collect();
63            (halves, TextureFormat::Rgba16Float)
64        } else {
65            // 8-bit layout, color space decided by the consuming slot.
66            (img.to_rgba8().into_raw(), TextureFormat::Rgba8Unorm)
67        };
68        let (width, height) = (img.width(), img.height());
69
70        Ok(CpuTexture {
71            pixels,
72            size: Extent3D {
73                width,
74                height,
75                depth_or_array_layers: 1,
76            },
77            format,
78            mip_level_count: 1,
79            sample_count: SampleCount::X1,
80            dimension: TextureDimension::D2,
81            usage: TextureUsage::COPY_DST | TextureUsage::TEXTURE_BINDING,
82        })
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn ldr_images_decode_to_a_neutral_eight_bit_layout() {
92        // 2x2 PNG, encoded in-memory so the test needs no asset on disk.
93        let mut png = Vec::new();
94        {
95            let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 255]));
96            image::DynamicImage::ImageRgba8(img)
97                .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
98                .expect("encode png");
99        }
100        let tex = TextureDecoder.load(&png).expect("decode png");
101        // Layout only — the sRGB/linear decision belongs to the material slot.
102        assert_eq!(tex.format, TextureFormat::Rgba8Unorm);
103        assert_eq!(tex.size.width, 2);
104        assert_eq!(tex.pixels.len(), 2 * 2 * 4);
105    }
106
107    #[test]
108    fn hdr_images_keep_their_range_as_half_float() {
109        // Radiance .hdr written in-memory, with a value well above 1.0 that an
110        // 8-bit decode would have clipped.
111        let mut hdr = Vec::new();
112        {
113            let pixels = vec![image::Rgb([4.0f32, 0.5, 0.25]); 4];
114            image::codecs::hdr::HdrEncoder::new(&mut hdr)
115                .encode(&pixels, 2, 2)
116                .expect("encode hdr");
117        }
118        let tex = TextureDecoder.load(&hdr).expect("decode hdr");
119        assert_eq!(tex.format, TextureFormat::Rgba16Float);
120        // 4 channels x 2 bytes per half.
121        assert_eq!(tex.pixels.len(), 2 * 2 * 4 * 2);
122        // The red channel must still exceed 1.0 — the whole point of HDR.
123        let red = u16::from_le_bytes([tex.pixels[0], tex.pixels[1]]);
124        assert_eq!(red, f32_to_f16_bits(4.0));
125    }
126}
127
128inventory::submit! {
129    DecoderRegistration {
130        type_name: "texture",
131        register: |svc| {
132            svc.register_decoder::<CpuTexture>("texture", TextureDecoder);
133        },
134    }
135}