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}