solstrale/material/
texture.rs

1//! Contains textures to be used by materials
2use std::error::Error;
3use std::sync::Arc;
4
5use enum_dispatch::enum_dispatch;
6use image::io::Reader;
7use image::RgbImage;
8use simple_error::SimpleError;
9
10use crate::geo::Uv;
11use crate::geo::vec3::Vec3;
12use crate::material::texture::BumpMap::{Height, Normal};
13use crate::material::texture::Textures::{ImageMapType, SolidColorType};
14use crate::util::height_map;
15use crate::util::rgb_color::rgb_to_vec3;
16
17/// Describes the color of a material.
18/// The color can vary by the uv coordinates of the hittable
19#[enum_dispatch]
20pub trait Texture {
21    /// Return the color of the texture at a given hit
22    fn color(&self, uv: Uv) -> Vec3;
23}
24
25#[enum_dispatch(Texture)]
26#[derive(Debug)]
27/// An enum of available textures types
28pub enum Textures {
29    /// [`Texture`] of the type [`SolidColor`]
30    SolidColorType(SolidColor),
31    /// [`Texture`] of the type [`ImageMap`]
32    ImageMapType(ImageMap),
33}
34
35impl Clone for Textures {
36    fn clone(&self) -> Self {
37        match self {
38            SolidColorType(t) => SolidColorType(t.clone()),
39            ImageMapType(t) => ImageMapType(t.clone()),
40        }
41    }
42}
43
44/// The variants of bump maps supported.
45pub enum BumpMap {
46    /// Each pixel in the image describes the normal vector directly
47    Normal(RgbImage),
48    /// Each pixel in the image describes the relative height in the surface
49    Height(RgbImage),
50}
51
52/// Load a bump map image texture and detect if it is a normal or height map
53fn load_bump_map(path: &str) -> Result<BumpMap, Box<dyn Error>> {
54    let mut reader = Reader::open(path).map_err(|err| {
55        SimpleError::new(format!("Failed to open bump texture {}: {}", path, err))
56    })?;
57    reader.no_limits();
58    reader = reader.with_guessed_format().map_err(|err| {
59        SimpleError::new(format!("Failed to load bump texture {}: {}", path, err))
60    })?;
61    let image = reader
62        .decode()
63        .map_err(|err| {
64            SimpleError::new(format!("Failed to decode bump texture {}: {}", path, err))
65        })?
66        .into_rgb8();
67
68    let mut num_normal = 0;
69    let mut num_height = 0;
70
71    for pixel in image.pixels() {
72        let p = rgb_to_vec3(pixel);
73        if (p.length() - 1.).abs() < 0.05 {
74            num_normal += 1;
75        }
76        if (p.x - p.y).abs() < 0.05 && (p.y - p.z).abs() < 0.05 {
77            num_height += 1;
78        }
79    }
80
81    if num_height > num_normal {
82        Ok(Height(image))
83    } else {
84        Ok(Normal(image))
85    }
86}
87
88/// Load a normal map texture. Source image can either be a normal or height map
89pub fn load_normal_texture(path: &str) -> Result<Textures, Box<dyn Error>> {
90    match load_bump_map(path)? {
91        Normal(n) => Ok(ImageMap::new(Arc::new(n))),
92        Height(h) => {
93            let n = height_map::to_normal_map(h);
94            Ok(ImageMap::new(Arc::new(n)))
95        }
96    }
97}
98
99/// A texture with just the same color everywhere
100#[derive(Clone, Debug)]
101pub struct SolidColor(Vec3);
102
103impl SolidColor {
104    #![allow(clippy::new_ret_no_self)]
105    /// Create a new solid color texture
106    pub fn new(r: f64, g: f64, b: f64) -> Textures {
107        SolidColor::new_from_vec3(Vec3::new(r, g, b))
108    }
109    /// Create a new solid color texture from an array
110    /// where colors are in the order r, g, b
111    pub fn new_from_f32_array(c: [f32; 3]) -> Textures {
112        SolidColor::new(c[0] as f64, c[1] as f64, c[2] as f64)
113    }
114    /// Create a new solid color texture from a [`Vec3`]
115    pub fn new_from_vec3(color: Vec3) -> Textures {
116        Textures::from(SolidColor(color))
117    }
118}
119
120impl Texture for SolidColor {
121    fn color(&self, _: Uv) -> Vec3 {
122        self.0
123    }
124}
125
126/// Texture that uses image data for color by loading the image from the path
127#[derive(Clone, Debug)]
128pub struct ImageMap {
129    image: Arc<RgbImage>,
130    max_x: f32,
131    max_y: f32,
132}
133
134impl ImageMap {
135    #![allow(clippy::new_ret_no_self)]
136    /// Creates a new image texture from a file path
137    pub fn load(path: &str) -> Result<Textures, Box<dyn Error>> {
138        let mut reader = Reader::open(path).map_err(|err| {
139            SimpleError::new(format!("Failed to open image texture {}: {}", path, err))
140        })?;
141        reader.no_limits();
142        reader = reader.with_guessed_format().map_err(|err| {
143            SimpleError::new(format!("Failed to load image texture {}: {}", path, err))
144        })?;
145        let image = reader
146            .decode()
147            .map_err(|err| {
148                SimpleError::new(format!("Failed to decode image texture {}: {}", path, err))
149            })?
150            .into_rgb8();
151
152        Ok(Self::new(Arc::new(image)))
153    }
154
155    /// Creates a texture that uses image data for color
156    pub fn new(image: Arc<RgbImage>) -> Textures {
157        let w = image.width();
158        let h = image.height();
159        Textures::from(ImageMap {
160            image,
161            max_x: w as f32 - 1.,
162            max_y: h as f32 - 1.,
163        })
164    }
165}
166
167impl Texture for ImageMap {
168    /// Returns the color in the image data that corresponds to the UV coordinate of the hittable
169    /// If UV coordinates from hit record is <0 or >1 texture wraps
170    fn color(&self, uv: Uv) -> Vec3 {
171        let u = uv.u.abs() % 1.;
172        let v = 1. - uv.v.abs() % 1.;
173
174        let x = u * self.max_x;
175        let y = v * self.max_y;
176
177        let pixel = self.image.get_pixel(x as u32, y as u32);
178        rgb_to_vec3(pixel)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use crate::material::texture::{BumpMap, load_bump_map};
185
186    #[test]
187    fn test_load_normal_bump_map() {
188        let res = load_bump_map("resources/textures/wall_n.png").unwrap();
189        match res {
190            BumpMap::Normal(n) => assert!(n.width() > 0 && n.height() > 0),
191            BumpMap::Height(_) => panic!("Should not be a height map"),
192        }
193    }
194
195    #[test]
196    fn test_load_height_bump_map() {
197        let res = load_bump_map("resources/textures/sponza-h.jpg").unwrap();
198        match res {
199            BumpMap::Normal(_) => panic!("Should not be a height map"),
200            BumpMap::Height(n) => assert!(n.width() > 0 && n.height() > 0),
201        }
202    }
203}