Skip to main content

mirage_engine/assets/
texture.rs

1use std::io::Cursor;
2
3use image::{ImageError, ImageReader, Limits};
4
5use crate::Error;
6use crate::math::UVec2;
7
8/// Largest a loaded texture may be, across and down: what every target
9/// Mirage draws to binds.
10const MAX_SIZE: u32 = 8192;
11
12/// The texture a mesh slot is sampled from: `8-bit` RGBA, sRGB-encoded, row
13/// by row from the top left.
14#[derive(Clone, Debug, Default, PartialEq)]
15pub struct TextureData {
16    size: UVec2,
17    pixels: Vec<u8>,
18    pixelated: bool,
19}
20
21impl TextureData {
22    /// A `size`-sized texture over `pixels`, `4` bytes per pixel.
23    ///
24    /// The length must match `size`; checked only in debug builds.
25    pub fn rgba8(size: UVec2, pixels: Vec<u8>) -> Self {
26        debug_assert_eq!(
27            pixels.len() as u64,
28            4 * u64::from(size.x) * u64::from(size.y),
29            "a {}x{} texture needs four bytes per pixel",
30            size.x,
31            size.y
32        );
33
34        Self {
35            size,
36            pixels,
37            pixelated: false,
38        }
39    }
40
41    /// Marks this texture sampled from the nearest texel, so that its pixels
42    /// stay pixels however large it is drawn; the default blends between them.
43    #[must_use]
44    pub fn pixelated(mut self) -> Self {
45        self.pixelated = true;
46        self
47    }
48
49    /// Pixels across and down; `(0, 0)` when there are none.
50    pub fn size(&self) -> UVec2 {
51        self.size
52    }
53
54    /// Whether the texture has pixels to upload.
55    pub(crate) fn drawn(&self) -> bool {
56        self.size.x > 0 && self.size.y > 0
57    }
58
59    /// The pixels, `4` bytes each, row by row from the top left.
60    pub fn pixels(&self) -> &[u8] {
61        &self.pixels
62    }
63}
64
65/// The relief a mesh slot reads: `8-bit` RGBA holding a normal and, where
66/// the constructor declares one, a depth per texel in place of color, row by
67/// row from the top left.
68///
69/// A relief holds no sampler of its own; it is sampled the way its slot's
70/// color texture is.
71#[derive(Clone, Debug, Default, PartialEq)]
72pub struct ReliefData {
73    map: TextureData,
74    deep: bool,
75}
76
77impl ReliefData {
78    /// A `size`-sized relief over `pixels`, `4` bytes per pixel: a normal in
79    /// `RGB` and a depth in `A`.
80    ///
81    /// The length must match `size`; checked only in debug builds.
82    pub fn rgba8(size: UVec2, pixels: Vec<u8>) -> Self {
83        Self::held(TextureData::rgba8(size, pixels), true)
84    }
85
86    /// The same over `pixels` holding normals alone, whose alpha byte no
87    /// draw reads.
88    ///
89    /// Required if you want a relief that lights a surface without moving
90    /// its texels off the plane; a `.glb` normal texture is read as one.
91    pub fn normals(size: UVec2, pixels: Vec<u8>) -> Self {
92        Self::held(TextureData::rgba8(size, pixels), false)
93    }
94
95    /// Pixels across and down; `(0, 0)` when there are none.
96    pub fn size(&self) -> UVec2 {
97        self.map.size()
98    }
99
100    /// The relief a source loaded as a texture, whose alpha holds a depth.
101    pub(crate) fn loaded(texture: TextureData) -> Self {
102        Self::held(texture, true)
103    }
104
105    /// Whether the relief holds a depth beside its normals, which is what
106    /// its constructor declared and never what its pixels hold.
107    pub(crate) fn deep(&self) -> bool {
108        self.deep
109    }
110
111    /// The pixels to upload, whose channels the shader reads as a normal and
112    /// a depth per texel.
113    pub(crate) fn map(&self) -> &TextureData {
114        &self.map
115    }
116
117    const fn held(map: TextureData, deep: bool) -> Self {
118        Self { map, deep }
119    }
120}
121
122/// The shading a mesh slot reads: `8-bit` RGBA holding a texel's occlusion,
123/// roughness and metallic in `R`, `G` and `B` in place of color, row by row
124/// from the top left.
125///
126/// A shading map holds no sampler of its own; it is sampled the way its
127/// slot's color texture is.
128#[derive(Clone, Debug, PartialEq)]
129pub struct ShadingData(TextureData);
130
131impl ShadingData {
132    /// A `size`-sized shading map over `pixels`, `4` bytes per pixel.
133    ///
134    /// The length must match `size`; checked only in debug builds.
135    pub fn rgba8(size: UVec2, pixels: Vec<u8>) -> Self {
136        Self(TextureData::rgba8(size, pixels))
137    }
138
139    /// The pixels to upload, whose channels the shader reads as an
140    /// occlusion, a roughness and a metallic per texel.
141    pub(crate) fn map(&self) -> &TextureData {
142        &self.0
143    }
144}
145
146/// The GPU side of slot textures: the samplers a slot reads through, the
147/// white pixel a slot with no texture is drawn against, or its shading and
148/// emissive maps read where it holds none, and the flat texel a slot with
149/// no relief reads.
150pub(crate) struct Textures {
151    layout: wgpu::BindGroupLayout,
152    blending: wgpu::Sampler,
153    nearest: wgpu::Sampler,
154    white: wgpu::Texture,
155    flat: wgpu::Texture,
156    fallback: wgpu::BindGroup,
157}
158
159impl Textures {
160    pub(crate) fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
161        let map = |binding| wgpu::BindGroupLayoutEntry {
162            binding,
163            visibility: wgpu::ShaderStages::FRAGMENT,
164            ty: wgpu::BindingType::Texture {
165                sample_type: wgpu::TextureSampleType::Float { filterable: true },
166                view_dimension: wgpu::TextureViewDimension::D2,
167                multisampled: false,
168            },
169            count: None,
170        };
171        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
172            label: Some("mirage-engine slot texture"),
173            entries: &[
174                map(0),
175                wgpu::BindGroupLayoutEntry {
176                    binding: 1,
177                    visibility: wgpu::ShaderStages::FRAGMENT,
178                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
179                    count: None,
180                },
181                map(2),
182                map(3),
183                map(4),
184            ],
185        });
186        let blending = sampler(device, wgpu::FilterMode::Linear);
187        let nearest = sampler(device, wgpu::FilterMode::Nearest);
188
189        let white = uploaded(
190            device,
191            queue,
192            &TextureData::rgba8(UVec2::ONE, vec![u8::MAX; 4]),
193            wgpu::TextureFormat::Rgba8UnormSrgb,
194        );
195        // The relief a slot with none reads: a normal straight out of the
196        // sprite, at no depth.
197        let flat = uploaded(
198            device,
199            queue,
200            &TextureData::rgba8(UVec2::ONE, vec![128, 128, u8::MAX, 0]),
201            wgpu::TextureFormat::Rgba8Unorm,
202        );
203        let fallback = bindings(device, &layout, &blending, [&white, &flat, &white, &white]);
204        Self {
205            layout,
206            blending,
207            nearest,
208            white,
209            flat,
210            fallback,
211        }
212    }
213
214    pub(crate) fn layout(&self) -> &wgpu::BindGroupLayout {
215        &self.layout
216    }
217
218    /// Fallback a slot with no texture samples: white, so shading is only
219    /// the slot's tint.
220    pub(crate) fn fallback(&self) -> &wgpu::BindGroup {
221        &self.fallback
222    }
223
224    /// Uploads a slot's `color` and the maps beside it and binds them
225    /// together, or `None` where none of them has pixels.
226    ///
227    /// The relief and the shading are uploaded raw: their channels hold
228    /// normals, depths and factors, not color. The slot's color chooses the
229    /// sampler, which all four read through.
230    pub(crate) fn bind(
231        &self,
232        device: &wgpu::Device,
233        queue: &wgpu::Queue,
234        color: Option<&TextureData>,
235        relief: Option<&ReliefData>,
236        shading: Option<&ShadingData>,
237        emissive: Option<&TextureData>,
238    ) -> Option<wgpu::BindGroup> {
239        let color = color.filter(|data| data.drawn());
240        let relief = relief.map(ReliefData::map).filter(|data| data.drawn());
241        let shading = shading.map(ShadingData::map).filter(|data| data.drawn());
242        let emissive = emissive.filter(|data| data.drawn());
243        if [color, relief, shading, emissive]
244            .iter()
245            .all(Option::is_none)
246        {
247            return None;
248        }
249        let sampler = match color.is_some_and(|data| data.pixelated) {
250            true => &self.nearest,
251            false => &self.blending,
252        };
253        let paint =
254            |data: &TextureData| uploaded(device, queue, data, wgpu::TextureFormat::Rgba8UnormSrgb);
255        let raw =
256            |data: &TextureData| uploaded(device, queue, data, wgpu::TextureFormat::Rgba8Unorm);
257        let (base, raised) = (color.map(paint), relief.map(raw));
258        let (scaled, cast) = (shading.map(raw), emissive.map(paint));
259
260        Some(bindings(
261            device,
262            &self.layout,
263            sampler,
264            [
265                base.as_ref().unwrap_or(&self.white),
266                raised.as_ref().unwrap_or(&self.flat),
267                scaled.as_ref().unwrap_or(&self.white),
268                cast.as_ref().unwrap_or(&self.white),
269            ],
270        ))
271    }
272}
273
274/// The texture `bytes` hold, up to [`MAX_SIZE`] pixels a side.
275///
276/// The size is read before any pixels are held, so a source that declares
277/// more than a target binds fails without holding the memory it declared.
278pub(crate) fn decode(bytes: &[u8]) -> Result<TextureData, Error> {
279    let mut reader = ImageReader::new(Cursor::new(bytes))
280        .with_guessed_format()
281        .map_err(|error| Error::msg(format!("did not decode: {error}")))?;
282    reader.limits(bounded());
283
284    let decoded = reader.decode().map_err(refused)?.into_rgba8();
285    let size = UVec2::new(decoded.width(), decoded.height());
286
287    Ok(TextureData::rgba8(size, decoded.into_raw()))
288}
289
290/// Largest a source may declare itself before the decoder stops reading it.
291fn bounded() -> Limits {
292    let mut limits = Limits::default();
293    limits.max_image_width = Some(MAX_SIZE);
294    limits.max_image_height = Some(MAX_SIZE);
295
296    limits
297}
298
299/// The error for a source that did not decode. Where the size cap stopped
300/// it, the error states the cap.
301fn refused(error: ImageError) -> Error {
302    match error {
303        ImageError::Limits(_) => Error::msg(format!(
304            "is larger than the {MAX_SIZE} pixels a side Mirage draws"
305        )),
306        error => Error::msg(format!("did not decode: {error}")),
307    }
308}
309
310/// Sample filter between a texture's texels, the one thing
311/// [`TextureData::pixelated`] changes.
312fn sampler(device: &wgpu::Device, filter: wgpu::FilterMode) -> wgpu::Sampler {
313    device.create_sampler(&wgpu::SamplerDescriptor {
314        label: Some("mirage-engine slot texture"),
315        address_mode_u: wgpu::AddressMode::Repeat,
316        address_mode_v: wgpu::AddressMode::Repeat,
317        address_mode_w: wgpu::AddressMode::Repeat,
318        mag_filter: filter,
319        min_filter: filter,
320        ..Default::default()
321    })
322}
323
324/// Binds `sampler` and the slot's color, relief, shading and emissive maps,
325/// in that order.
326fn bindings(
327    device: &wgpu::Device,
328    layout: &wgpu::BindGroupLayout,
329    sampler: &wgpu::Sampler,
330    maps: [&wgpu::Texture; 4],
331) -> wgpu::BindGroup {
332    let [color, relief, shading, emissive] = maps.map(|map| map.create_view(&Default::default()));
333    let map = |binding, view| wgpu::BindGroupEntry {
334        binding,
335        resource: wgpu::BindingResource::TextureView(view),
336    };
337    device.create_bind_group(&wgpu::BindGroupDescriptor {
338        label: Some("mirage-engine slot texture"),
339        layout,
340        entries: &[
341            map(0, &color),
342            wgpu::BindGroupEntry {
343                binding: 1,
344                resource: wgpu::BindingResource::Sampler(sampler),
345            },
346            map(2, &relief),
347            map(3, &shading),
348            map(4, &emissive),
349        ],
350    })
351}
352
353/// Uploads `data` as a texture of `format`.
354fn uploaded(
355    device: &wgpu::Device,
356    queue: &wgpu::Queue,
357    data: &TextureData,
358    format: wgpu::TextureFormat,
359) -> wgpu::Texture {
360    let extent = wgpu::Extent3d {
361        width: data.size().x,
362        height: data.size().y,
363        depth_or_array_layers: 1,
364    };
365    let texture = device.create_texture(&wgpu::TextureDescriptor {
366        label: Some("mirage-engine slot texture"),
367        size: extent,
368        mip_level_count: 1,
369        sample_count: 1,
370        dimension: wgpu::TextureDimension::D2,
371        format,
372        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
373        view_formats: &[],
374    });
375    queue.write_texture(
376        wgpu::TexelCopyTextureInfo {
377            texture: &texture,
378            mip_level: 0,
379            origin: wgpu::Origin3d::ZERO,
380            aspect: wgpu::TextureAspect::All,
381        },
382        data.pixels(),
383        wgpu::TexelCopyBufferLayout {
384            offset: 0,
385            bytes_per_row: Some(4 * data.size().x),
386            rows_per_image: Some(data.size().y),
387        },
388        extent,
389    );
390
391    texture
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::assets::IMP;
398
399    /// A source of `width` by `height` pixels, as a file would hold it.
400    fn png(width: u32, height: u32) -> Vec<u8> {
401        let mut out = Vec::new();
402        image::RgbaImage::new(width, height)
403            .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
404            .expect("the fixture encodes");
405
406        out
407    }
408
409    #[test]
410    fn a_source_larger_than_a_target_binds_does_not_decode() {
411        let error = decode(&png(MAX_SIZE + 1, 1)).expect_err("no target binds that");
412
413        assert_eq!(
414            error.to_string(),
415            format!("is larger than the {MAX_SIZE} pixels a side Mirage draws")
416        );
417        assert_eq!(
418            decode(&png(MAX_SIZE, 1))
419                .expect("the cap itself is drawn")
420                .size(),
421            UVec2::new(MAX_SIZE, 1),
422        );
423    }
424
425    #[test]
426    fn a_source_cut_off_anywhere_reads_as_itself_or_as_an_error() {
427        let whole = decode(IMP).expect("the fixture decodes").size();
428
429        for at in 0..IMP.len() {
430            if let Ok(cut) = decode(&IMP[..at]) {
431                assert_eq!(cut.size(), whole, "a cut at {at}");
432            }
433        }
434    }
435}