Skip to main content

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