Skip to main content

gizmo_renderer/asset/
texture.rs

1use super::decode_rgba_image_file;
2use std::sync::Arc;
3
4// ============================================================================
5//  Shared sampler descriptors
6// ============================================================================
7
8/// Standard sampler for real textures: bilinear, repeating.
9/// Mipmap filter is Nearest because we only allocate one mip level —
10/// using Linear here would trigger a wgpu validation warning.
11const SAMPLER_LINEAR_REPEAT: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor {
12    label: Some("linear_repeat_sampler"),
13    address_mode_u: wgpu::AddressMode::Repeat,
14    address_mode_v: wgpu::AddressMode::Repeat,
15    address_mode_w: wgpu::AddressMode::Repeat,
16    mag_filter: wgpu::FilterMode::Linear,
17    min_filter: wgpu::FilterMode::Linear,
18    mipmap_filter: wgpu::FilterMode::Nearest, // single mip — must be Nearest
19    lod_min_clamp: 0.0,
20    lod_max_clamp: 0.0,
21    compare: None,
22    anisotropy_clamp: 1,
23    border_color: None,
24};
25
26/// Point sampler for 1×1 fallback textures — no filtering needed.
27const SAMPLER_NEAREST_REPEAT: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor {
28    label: Some("nearest_repeat_sampler"),
29    address_mode_u: wgpu::AddressMode::Repeat,
30    address_mode_v: wgpu::AddressMode::Repeat,
31    address_mode_w: wgpu::AddressMode::Repeat,
32    mag_filter: wgpu::FilterMode::Nearest,
33    min_filter: wgpu::FilterMode::Nearest,
34    mipmap_filter: wgpu::FilterMode::Nearest,
35    lod_min_clamp: 0.0,
36    lod_max_clamp: 0.0,
37    compare: None,
38    anisotropy_clamp: 1,
39    border_color: None,
40};
41
42// ============================================================================
43//  AssetManager — texture methods
44// ============================================================================
45
46impl super::AssetManager {
47    // ── Internal helpers ──────────────────────────────────────────────────
48
49    /// Resolve a `path_or_uuid` argument to the string key used in
50    /// `texture_cache`.  Returns `(resolved_fs_path, cache_key)`.
51    ///
52    /// The cache key is the UUID string when one is registered, otherwise
53    /// the normalised filesystem path.  Keeping cache keys stable across
54    /// renames is why UUIDs are preferred.
55    fn resolve_texture_cache_key(&self, path_or_uuid: &str) -> Result<(String, String), String> {
56        let resolved = self.resolve_path_from_meta_source(path_or_uuid)?;
57        let cache_key = self
58            .get_uuid(&resolved)
59            .map(|id| id.to_string())
60            .unwrap_or_else(|| resolved.clone());
61        Ok((resolved, cache_key))
62    }
63
64    /// Upload a single RGBA8 pixel buffer to the GPU, cache the bind group,
65    /// and return it.
66    ///
67    /// Called by async loaders after decoding completes on a worker thread,
68    /// and by the procedural texture helpers below.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error when:
73    /// * `width` or `height` is zero (wgpu would panic on a zero-sized texture).
74    /// * `rgba.len()` does not equal `width * height * 4`.
75    pub fn install_decoded_material_texture(
76        &mut self,
77        device: &wgpu::Device,
78        queue: &wgpu::Queue,
79        layout: &wgpu::BindGroupLayout,
80        cache_key: &str,
81        rgba: &[u8],
82        width: u32,
83        height: u32,
84    ) -> Result<Arc<wgpu::BindGroup>, String> {
85        // Guard against zero-sized textures — wgpu panics on Extent3d { width:0, .. }.
86        if width == 0 || height == 0 {
87            return Err(format!(
88                "Cannot create texture with zero dimension: {width}×{height} (key={cache_key})"
89            ));
90        }
91
92        let expected = (width as usize)
93            .saturating_mul(height as usize)
94            .saturating_mul(4);
95
96        if rgba.len() != expected {
97            return Err(format!(
98                "RGBA size mismatch for '{cache_key}': got {} bytes, expected {expected} \
99                 ({width}×{height}×4)",
100                rgba.len()
101            ));
102        }
103
104        let texture_size = wgpu::Extent3d {
105            width,
106            height,
107            depth_or_array_layers: 1,
108        };
109
110        let texture = device.create_texture(&wgpu::TextureDescriptor {
111            size: texture_size,
112            mip_level_count: 1,
113            sample_count: 1,
114            dimension: wgpu::TextureDimension::D2,
115            format: wgpu::TextureFormat::Rgba8UnormSrgb,
116            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
117            label: Some(cache_key),
118            view_formats: &[],
119        });
120
121        queue.write_texture(
122            wgpu::ImageCopyTexture {
123                texture: &texture,
124                mip_level: 0,
125                origin: wgpu::Origin3d::ZERO,
126                aspect: wgpu::TextureAspect::All,
127            },
128            rgba,
129            wgpu::ImageDataLayout {
130                offset: 0,
131                bytes_per_row: Some(4 * width),
132                rows_per_image: Some(height),
133            },
134            texture_size,
135        );
136
137        let bg = self.build_bind_group(device, &texture, layout, &SAMPLER_LINEAR_REPEAT, cache_key);
138        self.texture_cache.insert(cache_key.to_string(), bg.clone());
139        Ok(bg)
140    }
141
142    // ── Public load API ───────────────────────────────────────────────────
143
144    /// Load a texture from `path_or_uuid`, uploading it to the GPU on first
145    /// access and returning the cached bind group on subsequent calls.
146    ///
147    /// Supports both filesystem paths and UUID strings registered by the asset
148    /// scanner.  Embedded assets (registered with [`AssetManager::embed_asset`])
149    /// take priority over filesystem reads.
150    pub fn load_material_texture(
151        &mut self,
152        device: &wgpu::Device,
153        queue: &wgpu::Queue,
154        layout: &wgpu::BindGroupLayout,
155        path_or_uuid: &str,
156    ) -> Result<Arc<wgpu::BindGroup>, String> {
157        let (resolved_path, cache_key) = self.resolve_texture_cache_key(path_or_uuid)?;
158
159        if let Some(cached) = self.texture_cache.get(&cache_key) {
160            return Ok(cached.clone());
161        }
162
163        let (rgba, w, h) = self.decode_texture_rgba(&resolved_path)?;
164        self.install_decoded_material_texture(device, queue, layout, &cache_key, &rgba, w, h)
165    }
166
167    /// Evict `path_or_uuid` from the texture cache and reload it from disk.
168    ///
169    /// Useful for hot-reload workflows where an asset file changes at runtime.
170    pub fn reload_material_texture(
171        &mut self,
172        device: &wgpu::Device,
173        queue: &wgpu::Queue,
174        layout: &wgpu::BindGroupLayout,
175        path_or_uuid: &str,
176    ) -> Result<Arc<wgpu::BindGroup>, String> {
177        // Resolve the key first so we evict the correct entry, then reload.
178        let (_, cache_key) = self.resolve_texture_cache_key(path_or_uuid)?;
179        self.texture_cache.remove(&cache_key);
180        self.load_material_texture(device, queue, layout, path_or_uuid)
181    }
182
183    // ── Procedural textures ───────────────────────────────────────────────
184
185    /// Return (creating once) a 1×1 opaque-white texture.
186    ///
187    /// Used as the default albedo map for materials that specify no texture.
188    pub fn create_white_texture(
189        &mut self,
190        device: &wgpu::Device,
191        queue: &wgpu::Queue,
192        layout: &wgpu::BindGroupLayout,
193    ) -> Arc<wgpu::BindGroup> {
194        const KEY: &str = "__white_fallback_texture__";
195
196        if let Some(cached) = self.texture_cache.get(KEY) {
197            return cached.clone();
198        }
199
200        let bg = self.upload_solid_1x1(device, queue, layout, [255, 255, 255, 255], KEY);
201        self.texture_cache.insert(KEY.to_string(), bg.clone());
202        bg
203    }
204
205    /// Return (creating once) a 256×256 grey checkerboard texture.
206    ///
207    /// Used for geometry whose material has no texture assigned — makes UVs
208    /// immediately visible in the editor.
209    pub fn create_checkerboard_texture(
210        &mut self,
211        device: &wgpu::Device,
212        queue: &wgpu::Queue,
213        layout: &wgpu::BindGroupLayout,
214    ) -> Arc<wgpu::BindGroup> {
215        const KEY: &str = "__checkerboard_texture__";
216        const SIZE: u32 = 256;
217        const CELL: u32 = 32; // pixels per checker square
218
219        if let Some(cached) = self.texture_cache.get(KEY) {
220            return cached.clone();
221        }
222
223        let mut pixels = vec![0u8; (SIZE * SIZE * 4) as usize];
224        for y in 0..SIZE {
225            for x in 0..SIZE {
226                let light = ((x / CELL) + (y / CELL)).is_multiple_of(2);
227                let luma = if light { 200u8 } else { 50u8 };
228                let base = ((y * SIZE + x) * 4) as usize;
229                pixels[base] = luma;
230                pixels[base + 1] = luma;
231                pixels[base + 2] = luma;
232                pixels[base + 3] = 255;
233            }
234        }
235
236        // SIZE and pixel count are compile-time constants; this cannot fail.
237
238        self.install_decoded_material_texture(device, queue, layout, KEY, &pixels, SIZE, SIZE)
239            .expect("checkerboard texture creation must not fail")
240    }
241
242    // ── Private GPU helpers ───────────────────────────────────────────────
243
244    /// Decode a texture file to RGBA8, preferring embedded data over disk.
245    fn decode_texture_rgba(&self, resolved_path: &str) -> Result<(Vec<u8>, u32, u32), String> {
246        if let Some(data) = self.embedded_assets.get(resolved_path) {
247            let img = image::load_from_memory(data)
248                .map_err(|e| format!("Embedded texture decode failed ({resolved_path}): {e}"))?
249                .to_rgba8();
250            let (w, h) = img.dimensions();
251            return Ok((img.into_raw(), w, h));
252        }
253
254        decode_rgba_image_file(resolved_path)
255    }
256
257    /// Upload a single RGBA pixel as a 1×1 texture and return its bind group.
258    ///
259    /// Uses the nearest-neighbour sampler — filtering a 1-pixel texture is
260    /// meaningless.
261    fn upload_solid_1x1(
262        &self,
263        device: &wgpu::Device,
264        queue: &wgpu::Queue,
265        layout: &wgpu::BindGroupLayout,
266        pixel: [u8; 4],
267        label: &str,
268    ) -> Arc<wgpu::BindGroup> {
269        let size = wgpu::Extent3d {
270            width: 1,
271            height: 1,
272            depth_or_array_layers: 1,
273        };
274
275        let texture = device.create_texture(&wgpu::TextureDescriptor {
276            size,
277            mip_level_count: 1,
278            sample_count: 1,
279            dimension: wgpu::TextureDimension::D2,
280            format: wgpu::TextureFormat::Rgba8UnormSrgb,
281            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
282            label: Some(label),
283            view_formats: &[],
284        });
285
286        queue.write_texture(
287            wgpu::ImageCopyTexture {
288                texture: &texture,
289                mip_level: 0,
290                origin: wgpu::Origin3d::ZERO,
291                aspect: wgpu::TextureAspect::All,
292            },
293            &pixel,
294            wgpu::ImageDataLayout {
295                offset: 0,
296                bytes_per_row: Some(4),
297                rows_per_image: Some(1),
298            },
299            size,
300        );
301
302        self.build_bind_group(device, &texture, layout, &SAMPLER_NEAREST_REPEAT, label)
303    }
304
305    /// Create a texture view + sampler and assemble a bind group.
306    ///
307    /// Centralises the boilerplate that would otherwise be duplicated in every
308    /// upload path.
309    fn build_bind_group(
310        &self,
311        device: &wgpu::Device,
312        texture: &wgpu::Texture,
313        layout: &wgpu::BindGroupLayout,
314        sampler_desc: &wgpu::SamplerDescriptor,
315        label: &str,
316    ) -> Arc<wgpu::BindGroup> {
317        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
318        let sampler = device.create_sampler(sampler_desc);
319
320        Arc::new(device.create_bind_group(&wgpu::BindGroupDescriptor {
321            label: Some(label),
322            layout,
323            entries: &[
324                wgpu::BindGroupEntry {
325                    binding: 0,
326                    resource: wgpu::BindingResource::TextureView(&view),
327                },
328                wgpu::BindGroupEntry {
329                    binding: 1,
330                    resource: wgpu::BindingResource::Sampler(&sampler),
331                },
332            ],
333        }))
334    }
335}