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