onedrop_renderer/
texture_pool.rs

1//! User-loaded texture pool for the comp pass.
2//!
3//! MilkDrop 2 lets preset authors reference image files on disk via
4//! `sampler sampler_<NAME>;` declarations in the comp shader, then sample them
5//! through `tex2D(sampler_<NAME>, uv)`. The image filename (stem) becomes the
6//! sampler name — `clouds.png` → `sampler_clouds`. Authors also reference
7//! `sampler_rand00..15` to pull a deterministic-per-preset random texture from
8//! whatever's on disk, optionally filtered by name prefix
9//! (`sampler_rand02_smalltiled` → only `smalltiled*.png`).
10//!
11//! This module owns the disk-loaded GPU textures. The comp pipeline reads
12//! through it at preset load time to populate the 8 user-texture binding slots.
13//! Lookup is by canonicalised name (lowercase stem, no extension); presets in
14//! the wild mix case freely (`sampler_Worms` vs `sampler_worms`).
15//!
16//! Pool population is best-effort:
17//! - Missing directory → empty pool (engine continues with the fallback 1×1
18//!   white texture). Logged once at debug level.
19//! - Unreadable / undecodable file → skipped with a warn log; rest of the
20//!   directory loads.
21//! - Name collisions across directories → first hit wins; later directories
22//!   only fill gaps. Matches MD2's "search path" semantics.
23
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27
28/// One GPU-resident user texture.
29pub struct UserTexture {
30    /// Canonicalised name — lowercase stem of the source filename, no
31    /// extension. `clouds.png` → `"clouds"`. Match against the `sampler_X`
32    /// portion of `sampler sampler_X;` MD2 declarations (lowercased).
33    pub name: String,
34    pub texture: wgpu::Texture,
35    pub view: wgpu::TextureView,
36    pub width: u32,
37    pub height: u32,
38}
39
40impl UserTexture {
41    /// `vec4<f32>(w, h, 1/w, 1/h)` — the `texsize_<NAME>` constant the comp
42    /// wrapper emits for this texture. Matches the MD2 convention used for
43    /// the noise pack.
44    pub fn texsize(&self) -> [f32; 4] {
45        let w = self.width as f32;
46        let h = self.height as f32;
47        [w, h, 1.0 / w, 1.0 / h]
48    }
49}
50
51/// Texture pool. Hold one per renderer; the comp pipeline borrows it whenever
52/// a preset's user shader needs to bind disk-loaded textures.
53pub struct TexturePool {
54    /// All loaded textures in disk-walk order. The order is stable across
55    /// runs as long as the underlying filesystem returns the same listing —
56    /// used by `pick_rand` to make `sampler_rand0X` deterministic.
57    textures: Vec<Arc<UserTexture>>,
58    /// Lookup by canonicalised name.
59    by_name: HashMap<String, usize>,
60    /// 1×1 opaque white. Bound into unfilled slots and returned when a
61    /// preset references a name that isn't on disk. Keeps every user
62    /// shader compileable + drawable even when the texture pool is empty.
63    fallback: Arc<UserTexture>,
64}
65
66impl TexturePool {
67    /// Build a pool with only the 1×1 white fallback. Tests and headless CI
68    /// paths use this; the real engine calls `from_dirs` after.
69    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
70        let fallback = Arc::new(make_fallback_texture(device, queue));
71        Self {
72            textures: Vec::new(),
73            by_name: HashMap::new(),
74            fallback,
75        }
76    }
77
78    /// Build a pool by scanning each directory in `dirs` for image files.
79    /// Earlier directories shadow later ones — pass user dirs before system
80    /// dirs if you want overrides.
81    ///
82    /// Decode errors are logged and the offending file skipped; missing
83    /// directories are silently treated as empty.
84    pub fn from_dirs(device: &wgpu::Device, queue: &wgpu::Queue, dirs: &[PathBuf]) -> Self {
85        let mut pool = Self::new(device, queue);
86        for dir in dirs {
87            pool.scan_dir(device, queue, dir);
88        }
89        pool
90    }
91
92    /// Append textures from one directory. Doesn't recurse — flat layout
93    /// matches MD2's `Textures/` convention.
94    pub fn scan_dir(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, dir: &Path) {
95        let entries = match std::fs::read_dir(dir) {
96            Ok(e) => e,
97            Err(err) => {
98                log::debug!("texture dir {} not readable: {}", dir.display(), err);
99                return;
100            }
101        };
102        for entry in entries.flatten() {
103            let path = entry.path();
104            if !path.is_file() {
105                continue;
106            }
107            let Some(name) = canonical_stem(&path) else {
108                continue;
109            };
110            if self.by_name.contains_key(&name) {
111                continue;
112            }
113            match load_texture_file(device, queue, &path, &name) {
114                Ok(tex) => {
115                    let idx = self.textures.len();
116                    self.textures.push(Arc::new(tex));
117                    self.by_name.insert(name, idx);
118                }
119                Err(e) => {
120                    log::warn!("failed to load texture {}: {}", path.display(), e);
121                }
122            }
123        }
124    }
125
126    /// Lookup by canonicalised name. `name` is compared against the lowercase
127    /// stem of the source filename — `get("clouds")` matches both
128    /// `clouds.png` and `Clouds.PNG`.
129    pub fn get(&self, name: &str) -> Option<&Arc<UserTexture>> {
130        let canon = name.to_ascii_lowercase();
131        self.by_name.get(&canon).map(|&i| &self.textures[i])
132    }
133
134    /// Always-non-None fallback — bound into unfilled user-texture slots so
135    /// the comp pass's bind group is complete even when the pool is empty
136    /// or a preset references a name we don't have.
137    pub fn fallback(&self) -> &Arc<UserTexture> {
138        &self.fallback
139    }
140
141    /// `true` when the pool has no user textures (only the fallback). The
142    /// renderer uses this to skip the disk-load scan path entirely on
143    /// `sampler_rand0X` resolution.
144    pub fn is_empty(&self) -> bool {
145        self.textures.is_empty()
146    }
147
148    /// Number of user textures (excludes the fallback).
149    pub fn len(&self) -> usize {
150        self.textures.len()
151    }
152
153    /// Deterministic-per-`seed` pick from the pool, optionally filtered by
154    /// name prefix. Returns `None` when no texture matches (caller can
155    /// substitute the fallback).
156    ///
157    /// MD2's `sampler_rand0X` slots want a stable choice for the lifetime
158    /// of a preset but a different choice across presets. The caller seeds
159    /// with `hash(preset_name) ^ slot_index` to achieve that.
160    pub fn pick_rand(&self, seed: u64, prefix: Option<&str>) -> Option<&Arc<UserTexture>> {
161        let pool_subset: Vec<usize> = match prefix {
162            Some(p) => {
163                let p = p.to_ascii_lowercase();
164                self.textures
165                    .iter()
166                    .enumerate()
167                    .filter(|(_, t)| t.name.starts_with(&p))
168                    .map(|(i, _)| i)
169                    .collect()
170            }
171            None => (0..self.textures.len()).collect(),
172        };
173        if pool_subset.is_empty() {
174            return None;
175        }
176        let idx = pool_subset[(seed as usize) % pool_subset.len()];
177        Some(&self.textures[idx])
178    }
179}
180
181/// XDG-default search paths for user textures. The renderer's caller
182/// (CLI / GUI) typically extends this with config-driven dirs.
183///
184/// Returns the candidate paths even when they don't exist on disk — the pool
185/// constructor silently skips missing dirs.
186pub fn default_texture_dirs() -> Vec<PathBuf> {
187    let mut out = Vec::new();
188    if let Some(home) = std::env::var_os("HOME") {
189        let home = PathBuf::from(home);
190        out.push(home.join(".config/onedrop/textures"));
191        // MD2 historical layout — many users drop their texture pack under
192        // `~/Music/milkdrop/textures` along with the presets.
193        out.push(home.join("Music/milkdrop/textures"));
194    }
195    if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
196        out.push(PathBuf::from(xdg).join("onedrop/textures"));
197    }
198    out
199}
200
201fn canonical_stem(path: &Path) -> Option<String> {
202    let stem = path.file_stem()?.to_str()?.to_ascii_lowercase();
203    if stem.is_empty() {
204        return None;
205    }
206    Some(stem)
207}
208
209fn load_texture_file(
210    device: &wgpu::Device,
211    queue: &wgpu::Queue,
212    path: &Path,
213    name: &str,
214) -> Result<UserTexture, image::ImageError> {
215    let img = image::open(path)?;
216    let rgba = img.to_rgba8();
217    let (width, height) = rgba.dimensions();
218    Ok(upload_rgba8(device, queue, name, width, height, &rgba))
219}
220
221fn upload_rgba8(
222    device: &wgpu::Device,
223    queue: &wgpu::Queue,
224    name: &str,
225    width: u32,
226    height: u32,
227    bytes: &[u8],
228) -> UserTexture {
229    let texture = device.create_texture(&wgpu::TextureDescriptor {
230        label: Some(&format!("UserTexture {name}")),
231        size: wgpu::Extent3d {
232            width,
233            height,
234            depth_or_array_layers: 1,
235        },
236        mip_level_count: 1,
237        sample_count: 1,
238        dimension: wgpu::TextureDimension::D2,
239        format: wgpu::TextureFormat::Rgba8Unorm,
240        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
241        view_formats: &[],
242    });
243    queue.write_texture(
244        wgpu::TexelCopyTextureInfo {
245            texture: &texture,
246            mip_level: 0,
247            origin: wgpu::Origin3d::ZERO,
248            aspect: wgpu::TextureAspect::All,
249        },
250        bytes,
251        wgpu::TexelCopyBufferLayout {
252            offset: 0,
253            bytes_per_row: Some(width * 4),
254            rows_per_image: Some(height),
255        },
256        wgpu::Extent3d {
257            width,
258            height,
259            depth_or_array_layers: 1,
260        },
261    );
262    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
263    UserTexture {
264        name: name.to_string(),
265        texture,
266        view,
267        width,
268        height,
269    }
270}
271
272fn make_fallback_texture(device: &wgpu::Device, queue: &wgpu::Queue) -> UserTexture {
273    // 1×1 opaque white — sampling it yields `vec4(1, 1, 1, 1)` regardless of
274    // uv, so any `tex2D(sampler_missing, uv) * something` math degrades to
275    // `something` (the well-behaved no-op for multiplicative texture use).
276    upload_rgba8(
277        device,
278        queue,
279        "__fallback_white",
280        1,
281        1,
282        &[255, 255, 255, 255],
283    )
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::config::RenderConfig;
290    use crate::gpu_context::GpuContext;
291
292    /// Sythesise a 4×4 RGBA8 PNG in-place. Avoids dragging a fixture file
293    /// into the repo for tests that just need *some* decodable image.
294    fn write_solid_png(path: &Path, color: [u8; 4]) {
295        let pixels: Vec<u8> = (0..16).flat_map(|_| color).collect();
296        let img =
297            image::RgbaImage::from_raw(4, 4, pixels).expect("RgbaImage::from_raw must succeed");
298        img.save_with_format(path, image::ImageFormat::Png).unwrap();
299    }
300
301    #[test]
302    fn empty_pool_has_fallback() {
303        let cfg = RenderConfig::default();
304        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
305        let pool = TexturePool::new(&gpu.device, &gpu.queue);
306        assert!(pool.is_empty());
307        assert_eq!(pool.len(), 0);
308        assert!(pool.get("anything").is_none());
309        // Fallback is always present, 1×1.
310        assert_eq!(pool.fallback().width, 1);
311        assert_eq!(pool.fallback().height, 1);
312    }
313
314    #[test]
315    fn from_dirs_loads_pngs() {
316        let cfg = RenderConfig::default();
317        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
318        let dir = tempfile::tempdir().unwrap();
319        write_solid_png(&dir.path().join("Clouds.png"), [10, 20, 30, 255]);
320        write_solid_png(&dir.path().join("worms.png"), [40, 50, 60, 255]);
321
322        let pool = TexturePool::from_dirs(&gpu.device, &gpu.queue, &[dir.path().to_path_buf()]);
323        assert_eq!(pool.len(), 2);
324        // Case-insensitive lookup — `Clouds.png` is indexed as `clouds`.
325        assert!(pool.get("clouds").is_some());
326        assert!(pool.get("Clouds").is_some());
327        assert!(pool.get("worms").is_some());
328        assert!(pool.get("missing").is_none());
329    }
330
331    #[test]
332    fn missing_dir_is_skipped() {
333        let cfg = RenderConfig::default();
334        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
335        let pool = TexturePool::from_dirs(
336            &gpu.device,
337            &gpu.queue,
338            &[PathBuf::from("/definitely/does/not/exist/onedrop-test")],
339        );
340        // No panic; fallback still present.
341        assert!(pool.is_empty());
342    }
343
344    #[test]
345    fn name_collision_first_wins() {
346        let cfg = RenderConfig::default();
347        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
348        let dir_a = tempfile::tempdir().unwrap();
349        let dir_b = tempfile::tempdir().unwrap();
350        write_solid_png(&dir_a.path().join("clouds.png"), [10, 20, 30, 255]);
351        write_solid_png(&dir_b.path().join("clouds.png"), [200, 210, 220, 255]);
352
353        let pool = TexturePool::from_dirs(
354            &gpu.device,
355            &gpu.queue,
356            &[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()],
357        );
358        // Both directories had `clouds.png` but only the first one wins.
359        assert_eq!(pool.len(), 1);
360        assert!(pool.get("clouds").is_some());
361    }
362
363    #[test]
364    fn pick_rand_is_deterministic() {
365        let cfg = RenderConfig::default();
366        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
367        let dir = tempfile::tempdir().unwrap();
368        for n in ["alpha", "beta", "gamma"] {
369            write_solid_png(&dir.path().join(format!("{n}.png")), [0, 0, 0, 255]);
370        }
371        let pool = TexturePool::from_dirs(&gpu.device, &gpu.queue, &[dir.path().to_path_buf()]);
372        // Same seed twice → same pick.
373        let a = pool.pick_rand(42, None).map(|t| t.name.clone());
374        let b = pool.pick_rand(42, None).map(|t| t.name.clone());
375        assert_eq!(a, b);
376        assert!(a.is_some());
377    }
378
379    #[test]
380    fn pick_rand_with_prefix_filters() {
381        let cfg = RenderConfig::default();
382        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
383        let dir = tempfile::tempdir().unwrap();
384        write_solid_png(&dir.path().join("smalltiled_a.png"), [0; 4]);
385        write_solid_png(&dir.path().join("smalltiled_b.png"), [0; 4]);
386        write_solid_png(&dir.path().join("unrelated.png"), [0; 4]);
387        let pool = TexturePool::from_dirs(&gpu.device, &gpu.queue, &[dir.path().to_path_buf()]);
388
389        // Every seed must land on a `smalltiled_*` texture when the prefix
390        // filter is active.
391        for seed in 0..16 {
392            let pick = pool.pick_rand(seed, Some("smalltiled_")).unwrap();
393            assert!(
394                pick.name.starts_with("smalltiled_"),
395                "seed {seed} picked {}",
396                pick.name
397            );
398        }
399        // Empty prefix subset → None.
400        assert!(pool.pick_rand(0, Some("nothing_matches_")).is_none());
401    }
402
403    #[test]
404    fn pick_rand_on_empty_pool_is_none() {
405        let cfg = RenderConfig::default();
406        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
407        let pool = TexturePool::new(&gpu.device, &gpu.queue);
408        assert!(pool.pick_rand(0, None).is_none());
409    }
410
411    #[test]
412    fn texsize_matches_dimensions() {
413        let cfg = RenderConfig::default();
414        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
415        let dir = tempfile::tempdir().unwrap();
416        write_solid_png(&dir.path().join("a.png"), [0; 4]);
417        let pool = TexturePool::from_dirs(&gpu.device, &gpu.queue, &[dir.path().to_path_buf()]);
418        let t = pool.get("a").unwrap();
419        let v = t.texsize();
420        assert_eq!(v[0], 4.0);
421        assert_eq!(v[1], 4.0);
422        assert!((v[2] - 0.25).abs() < 1e-6);
423        assert!((v[3] - 0.25).abs() < 1e-6);
424    }
425}