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    pub fn create_uv_debug_texture(
243        &mut self,
244        device: &wgpu::Device,
245        queue: &wgpu::Queue,
246        layout: &wgpu::BindGroupLayout,
247    ) -> Arc<wgpu::BindGroup> {
248        const KEY: &str = "__uv_debug_texture__";
249        const TEXTURE_SIZE: usize = 8;
250
251        if let Some(cached) = self.texture_cache.get(KEY) {
252            return cached.clone();
253        }
254
255        let mut palette: [u8; 32] = [
256            255, 102, 159, 255, 255, 159, 102, 255, 236, 255, 102, 255, 121, 255, 102, 255, 102, 255,
257            198, 255, 102, 198, 255, 255, 121, 102, 255, 255, 236, 102, 255, 255,
258        ];
259
260        let mut texture_data = [0u8; TEXTURE_SIZE * TEXTURE_SIZE * 4];
261        for y in 0..TEXTURE_SIZE {
262            let offset = TEXTURE_SIZE * y * 4;
263            texture_data[offset..(offset + TEXTURE_SIZE * 4)].copy_from_slice(&palette);
264            palette.rotate_right(4);
265        }
266
267        self.install_decoded_material_texture(
268            device,
269            queue,
270            layout,
271            KEY,
272            &texture_data,
273            TEXTURE_SIZE as u32,
274            TEXTURE_SIZE as u32,
275        )
276        .expect("uv debug texture creation must not fail")
277    }
278
279    // ── Private GPU helpers ───────────────────────────────────────────────
280
281    /// Decode a texture file to RGBA8, preferring embedded data over disk.
282    fn decode_texture_rgba(&self, resolved_path: &str) -> Result<(Vec<u8>, u32, u32), String> {
283        if let Some(data) = self.embedded_assets.get(resolved_path) {
284            let img = image::load_from_memory(data)
285                .map_err(|e| format!("Embedded texture decode failed ({resolved_path}): {e}"))?
286                .to_rgba8();
287            let (w, h) = img.dimensions();
288            return Ok((img.into_raw(), w, h));
289        }
290
291        decode_rgba_image_file(resolved_path)
292    }
293
294    /// Upload a single RGBA pixel as a 1×1 texture and return its bind group.
295    ///
296    /// Uses the nearest-neighbour sampler — filtering a 1-pixel texture is
297    /// meaningless.
298    fn upload_solid_1x1(
299        &self,
300        device: &wgpu::Device,
301        queue: &wgpu::Queue,
302        layout: &wgpu::BindGroupLayout,
303        pixel: [u8; 4],
304        label: &str,
305    ) -> Arc<wgpu::BindGroup> {
306        let size = wgpu::Extent3d {
307            width: 1,
308            height: 1,
309            depth_or_array_layers: 1,
310        };
311
312        let texture = device.create_texture(&wgpu::TextureDescriptor {
313            size,
314            mip_level_count: 1,
315            sample_count: 1,
316            dimension: wgpu::TextureDimension::D2,
317            format: wgpu::TextureFormat::Rgba8UnormSrgb,
318            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
319            label: Some(label),
320            view_formats: &[],
321        });
322
323        queue.write_texture(
324            wgpu::ImageCopyTexture {
325                texture: &texture,
326                mip_level: 0,
327                origin: wgpu::Origin3d::ZERO,
328                aspect: wgpu::TextureAspect::All,
329            },
330            &pixel,
331            wgpu::ImageDataLayout {
332                offset: 0,
333                bytes_per_row: Some(4),
334                rows_per_image: Some(1),
335            },
336            size,
337        );
338
339        self.build_bind_group(device, &texture, layout, &SAMPLER_NEAREST_REPEAT, label)
340    }
341
342    /// Create a texture view + sampler and assemble a bind group.
343    ///
344    /// Centralises the boilerplate that would otherwise be duplicated in every
345    /// upload path.
346    fn build_bind_group(
347        &self,
348        device: &wgpu::Device,
349        texture: &wgpu::Texture,
350        layout: &wgpu::BindGroupLayout,
351        sampler_desc: &wgpu::SamplerDescriptor,
352        label: &str,
353    ) -> Arc<wgpu::BindGroup> {
354        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
355        let sampler = device.create_sampler(sampler_desc);
356
357        Arc::new(device.create_bind_group(&wgpu::BindGroupDescriptor {
358            label: Some(label),
359            layout,
360            entries: &[
361                wgpu::BindGroupEntry {
362                    binding: 0,
363                    resource: wgpu::BindingResource::TextureView(&view),
364                },
365                wgpu::BindGroupEntry {
366                    binding: 1,
367                    resource: wgpu::BindingResource::Sampler(&sampler),
368                },
369            ],
370        }))
371    }
372}