Skip to main content

game_toolkit_gfx/
texture.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5
6#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
7pub struct TextureId(pub u32);
8
9/// What a texture shows for UVs outside `0..1`. Set per texture with
10/// [`crate::Graphics::set_texture_wrap`].
11#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
12pub enum TextureWrap {
13    /// Repeat the edge texels (default). Right for sprites and atlas regions, which sample up
14    /// to their edges and would bleed in the opposite edge with wrapping.
15    #[default]
16    Clamp,
17    /// Tile the texture: a UV of `2.5` samples the same texel as `0.5`.
18    Repeat,
19    /// Tile, mirroring every other repetition so the seams match.
20    MirrorRepeat,
21}
22
23impl TextureWrap {
24    const ALL: [Self; 3] = [Self::Clamp, Self::Repeat, Self::MirrorRepeat];
25
26    fn address_mode(self) -> wgpu::AddressMode {
27        match self {
28            Self::Clamp => wgpu::AddressMode::ClampToEdge,
29            Self::Repeat => wgpu::AddressMode::Repeat,
30            Self::MirrorRepeat => wgpu::AddressMode::MirrorRepeat,
31        }
32    }
33
34    /// Position in [`TextureWrap::ALL`] and the registry's sampler array.
35    fn index(self) -> usize {
36        match self {
37            Self::Clamp => 0,
38            Self::Repeat => 1,
39            Self::MirrorRepeat => 2,
40        }
41    }
42}
43
44pub struct Texture {
45    pub size: [u32; 2],
46    pub(crate) bind_group: wgpu::BindGroup,
47    pub(crate) texture: wgpu::Texture,
48    pub(crate) view: wgpu::TextureView,
49    pub(crate) wrap: TextureWrap,
50    /// Debug label, reapplied when the bind group is rebuilt for a new wrap mode.
51    pub(crate) label: Option<String>,
52}
53
54pub(crate) struct TextureRegistry {
55    map: HashMap<TextureId, Texture>,
56    next: u32,
57    pub(crate) layout: wgpu::BindGroupLayout,
58    /// One sampler per [`TextureWrap`], indexed by [`TextureWrap::index`].
59    samplers: [wgpu::Sampler; 3],
60    white: TextureId,
61}
62
63impl TextureRegistry {
64    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
65        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
66            label: Some("sprite.texture_bgl"),
67            entries: &[
68                wgpu::BindGroupLayoutEntry {
69                    binding: 0,
70                    visibility: wgpu::ShaderStages::FRAGMENT,
71                    ty: wgpu::BindingType::Texture {
72                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
73                        view_dimension: wgpu::TextureViewDimension::D2,
74                        multisampled: false,
75                    },
76                    count: None,
77                },
78                wgpu::BindGroupLayoutEntry {
79                    binding: 1,
80                    visibility: wgpu::ShaderStages::FRAGMENT,
81                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
82                    count: None,
83                },
84            ],
85        });
86
87        let samplers = TextureWrap::ALL.map(|wrap| {
88            let mode = wrap.address_mode();
89            device.create_sampler(&wgpu::SamplerDescriptor {
90                label: Some("sprite.sampler"),
91                address_mode_u: mode,
92                address_mode_v: mode,
93                address_mode_w: mode,
94                mag_filter: wgpu::FilterMode::Nearest,
95                min_filter: wgpu::FilterMode::Nearest,
96                mipmap_filter: wgpu::MipmapFilterMode::Nearest,
97                ..Default::default()
98            })
99        });
100
101        let mut me = Self {
102            map: HashMap::new(),
103            next: 1,
104            layout,
105            samplers,
106            white: TextureId(0),
107        };
108        me.white = me.create_from_rgba(device, queue, 1, 1, &[255, 255, 255, 255], Some("white"));
109        me
110    }
111
112    pub fn white(&self) -> TextureId {
113        self.white
114    }
115
116    pub fn bind_group(&self, id: TextureId) -> &wgpu::BindGroup {
117        &self
118            .map
119            .get(&id)
120            .unwrap_or_else(|| self.map.get(&self.white).expect("white texture"))
121            .bind_group
122    }
123
124    pub fn get(&self, id: TextureId) -> Option<&Texture> {
125        self.map.get(&id)
126    }
127
128    /// Switch `id` to `wrap` by rebuilding its bind group with the matching sampler. Returns
129    /// `false` for an unknown id.
130    pub fn set_wrap(&mut self, device: &wgpu::Device, id: TextureId, wrap: TextureWrap) -> bool {
131        let Some(tex) = self.map.get(&id) else {
132            return false;
133        };
134        if tex.wrap != wrap {
135            let bind_group = self.create_bind_group(device, &tex.view, wrap, tex.label.as_deref());
136            let tex = self.map.get_mut(&id).expect("checked above");
137            tex.bind_group = bind_group;
138            tex.wrap = wrap;
139        }
140        true
141    }
142
143    fn create_bind_group(
144        &self,
145        device: &wgpu::Device,
146        view: &wgpu::TextureView,
147        wrap: TextureWrap,
148        label: Option<&str>,
149    ) -> wgpu::BindGroup {
150        device.create_bind_group(&wgpu::BindGroupDescriptor {
151            label,
152            layout: &self.layout,
153            entries: &[
154                wgpu::BindGroupEntry {
155                    binding: 0,
156                    resource: wgpu::BindingResource::TextureView(view),
157                },
158                wgpu::BindGroupEntry {
159                    binding: 1,
160                    resource: wgpu::BindingResource::Sampler(&self.samplers[wrap.index()]),
161                },
162            ],
163        })
164    }
165
166    #[cfg(not(target_arch = "wasm32"))]
167    pub fn load_file(
168        &mut self,
169        device: &wgpu::Device,
170        queue: &wgpu::Queue,
171        path: impl AsRef<Path>,
172    ) -> Result<TextureId> {
173        let path = path.as_ref();
174        let img = image::open(path).with_context(|| format!("loading {}", path.display()))?;
175        let rgba = img.to_rgba8();
176        let (w, h) = rgba.dimensions();
177        Ok(self.create_from_rgba(
178            device,
179            queue,
180            w,
181            h,
182            rgba.as_raw(),
183            path.file_name().and_then(|s| s.to_str()),
184        ))
185    }
186
187    /// Decode an encoded image (PNG) held in memory and upload it.
188    pub fn load_bytes(
189        &mut self,
190        device: &wgpu::Device,
191        queue: &wgpu::Queue,
192        bytes: &[u8],
193        label: Option<&str>,
194    ) -> Result<TextureId> {
195        let img = image::load_from_memory(bytes)
196            .with_context(|| format!("decoding {}", label.unwrap_or("image")))?;
197        let rgba = img.to_rgba8();
198        let (w, h) = rgba.dimensions();
199        Ok(self.create_from_rgba(device, queue, w, h, rgba.as_raw(), label))
200    }
201
202    /// Replace the contents of `id` with a freshly decoded copy of `path`. Keeps the same
203    /// `TextureId` so anything already holding it stays valid.
204    pub fn reload(
205        &mut self,
206        device: &wgpu::Device,
207        queue: &wgpu::Queue,
208        id: TextureId,
209        path: impl AsRef<Path>,
210    ) -> Result<()> {
211        let path = path.as_ref();
212        let img = image::open(path).with_context(|| format!("reloading {}", path.display()))?;
213        let rgba = img.to_rgba8();
214        let (w, h) = rgba.dimensions();
215        self.replace(
216            device,
217            queue,
218            id,
219            w,
220            h,
221            rgba.as_raw(),
222            path.file_name().and_then(|s| s.to_str()),
223        );
224        Ok(())
225    }
226
227    #[allow(clippy::too_many_arguments)]
228    pub fn replace(
229        &mut self,
230        device: &wgpu::Device,
231        queue: &wgpu::Queue,
232        id: TextureId,
233        width: u32,
234        height: u32,
235        rgba: &[u8],
236        label: Option<&str>,
237    ) {
238        let wrap = self.map.get(&id).map(|t| t.wrap).unwrap_or_default();
239        let new_id = self.create_from_rgba(device, queue, width, height, rgba, label);
240        // Keep the wrap mode across hot-reloads.
241        self.set_wrap(device, new_id, wrap);
242        if let Some(new_tex) = self.map.remove(&new_id) {
243            self.map.insert(id, new_tex);
244        }
245    }
246
247    /// Overwrite the pixels of `id` in place. `rgba` must hold exactly `width * height * 4`
248    /// bytes for the texture's current size. Returns `false` (and writes nothing) for an
249    /// unknown id or a size mismatch.
250    pub fn write_rgba(&self, queue: &wgpu::Queue, id: TextureId, rgba: &[u8]) -> bool {
251        let Some(tex) = self.map.get(&id) else {
252            return false;
253        };
254        // A texture registered from outside may not be writable or 4 bytes per texel.
255        if !tex.texture.usage().contains(wgpu::TextureUsages::COPY_DST)
256            || tex.texture.format().block_copy_size(None) != Some(4)
257        {
258            return false;
259        }
260        let [width, height] = tex.size;
261        // u64 so the expected size cannot wrap on 32-bit targets (wasm32).
262        if rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 {
263            return false;
264        }
265        queue.write_texture(
266            wgpu::TexelCopyTextureInfo {
267                texture: &tex.texture,
268                mip_level: 0,
269                origin: wgpu::Origin3d::ZERO,
270                aspect: wgpu::TextureAspect::All,
271            },
272            rgba,
273            wgpu::TexelCopyBufferLayout {
274                offset: 0,
275                bytes_per_row: Some(4 * width),
276                rows_per_image: Some(height),
277            },
278            wgpu::Extent3d {
279                width,
280                height,
281                depth_or_array_layers: 1,
282            },
283        );
284        true
285    }
286
287    /// Adopt a texture created elsewhere, drawn through `view`.
288    pub fn register(
289        &mut self,
290        device: &wgpu::Device,
291        texture: wgpu::Texture,
292        view: wgpu::TextureView,
293        label: Option<&str>,
294    ) -> TextureId {
295        let bind_group = self.create_bind_group(device, &view, TextureWrap::Clamp, label);
296        let id = TextureId(self.next);
297        self.next += 1;
298        self.map.insert(
299            id,
300            Texture {
301                size: [texture.width(), texture.height()],
302                bind_group,
303                texture,
304                view,
305                wrap: TextureWrap::Clamp,
306                label: label.map(str::to_owned),
307            },
308        );
309        id
310    }
311
312    pub fn create_from_rgba(
313        &mut self,
314        device: &wgpu::Device,
315        queue: &wgpu::Queue,
316        width: u32,
317        height: u32,
318        rgba: &[u8],
319        label: Option<&str>,
320    ) -> TextureId {
321        let size = wgpu::Extent3d {
322            width,
323            height,
324            depth_or_array_layers: 1,
325        };
326        let texture = device.create_texture(&wgpu::TextureDescriptor {
327            label,
328            size,
329            mip_level_count: 1,
330            sample_count: 1,
331            dimension: wgpu::TextureDimension::D2,
332            format: wgpu::TextureFormat::Rgba8UnormSrgb,
333            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
334            view_formats: &[],
335        });
336        queue.write_texture(
337            wgpu::TexelCopyTextureInfo {
338                texture: &texture,
339                mip_level: 0,
340                origin: wgpu::Origin3d::ZERO,
341                aspect: wgpu::TextureAspect::All,
342            },
343            rgba,
344            wgpu::TexelCopyBufferLayout {
345                offset: 0,
346                bytes_per_row: Some(4 * width),
347                rows_per_image: Some(height),
348            },
349            size,
350        );
351        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
352        self.register(device, texture, view, label)
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn index_matches_position_in_all() {
362        for (i, wrap) in TextureWrap::ALL.into_iter().enumerate() {
363            assert_eq!(wrap.index(), i);
364        }
365    }
366}