Skip to main content

teksilo_render/
image_manager.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Image texture manager: maps image names to GPU textures for DrawCommand::Image rendering.
5//!
6//! Every image is uploaded with a full **mip chain** and sampled trilinearly, so
7//! a large source drawn small (a 512 px app icon in a 25 dp title bar, a photo
8//! in a thumbnail strip) resolves cleanly instead of aliasing. See
9//! the `mipmap` module for how the chain is built — and for the two things that
10//! make it correct rather than merely present (linear-light averaging, and
11//! premultiplied filtering so transparent texels can't darken their
12//! neighbours).
13
14use std::collections::HashMap;
15
16use crate::mipmap::build_mip_chain;
17
18/// Manages uploaded image textures and their bind groups.
19#[derive(Default)]
20pub struct ImageManager {
21    images: HashMap<String, ImageEntry>,
22}
23
24struct ImageEntry {
25    _texture: wgpu::Texture,
26    bind_group: wgpu::BindGroup,
27}
28
29impl ImageManager {
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Register an image by name. Uploads RGBA pixel data and creates a bind group
35    /// compatible with the quad pipeline's bind group layout.
36    #[allow(clippy::too_many_arguments)]
37    pub fn register_image(
38        &mut self,
39        name: &str,
40        width: u32,
41        height: u32,
42        pixels: &[u8],
43        device: &wgpu::Device,
44        queue: &wgpu::Queue,
45        bind_group_layout: &wgpu::BindGroupLayout,
46    ) {
47        if width == 0 || height == 0 {
48            return;
49        }
50
51        // Levels 1..N (level 0 is `pixels`). Built once, at upload.
52        let mips = build_mip_chain(pixels, width, height);
53
54        let texture = device.create_texture(&wgpu::TextureDescriptor {
55            label: Some("image_texture"),
56            size: wgpu::Extent3d {
57                width,
58                height,
59                depth_or_array_layers: 1,
60            },
61            mip_level_count: 1 + mips.len() as u32,
62            sample_count: 1,
63            dimension: wgpu::TextureDimension::D2,
64            format: wgpu::TextureFormat::Rgba8UnormSrgb,
65            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
66            view_formats: &[],
67        });
68
69        // Level 0, then each generated level. A texture declaring mip levels it
70        // never receives samples as transparent black wherever the sampler
71        // reaches them, so every declared level must be written.
72        let upload = |level: u32, w: u32, h: u32, data: &[u8]| {
73            queue.write_texture(
74                wgpu::TexelCopyTextureInfo {
75                    texture: &texture,
76                    mip_level: level,
77                    origin: wgpu::Origin3d::ZERO,
78                    aspect: wgpu::TextureAspect::All,
79                },
80                data,
81                wgpu::TexelCopyBufferLayout {
82                    offset: 0,
83                    bytes_per_row: Some(w * 4),
84                    rows_per_image: Some(h),
85                },
86                wgpu::Extent3d {
87                    width: w,
88                    height: h,
89                    depth_or_array_layers: 1,
90                },
91            );
92        };
93        upload(0, width, height, pixels);
94        for (level, (w, h, data)) in mips.iter().enumerate() {
95            upload(level as u32 + 1, *w, *h, data);
96        }
97
98        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
99        // Trilinear: `mipmap_filter` is what actually engages the chain. Left at
100        // its `Nearest` default, a minified image snaps between whole levels and
101        // visibly pops as the scale crosses a power of two — and with a
102        // single-level texture (the pre-mip behaviour) it would never leave
103        // level 0 at all, which is the aliasing this exists to remove.
104        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
105            mag_filter: wgpu::FilterMode::Linear,
106            min_filter: wgpu::FilterMode::Linear,
107            mipmap_filter: wgpu::MipmapFilterMode::Linear,
108            ..Default::default()
109        });
110
111        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
112            label: Some("image_bind_group"),
113            layout: bind_group_layout,
114            entries: &[
115                wgpu::BindGroupEntry {
116                    binding: 0,
117                    resource: wgpu::BindingResource::TextureView(&view),
118                },
119                wgpu::BindGroupEntry {
120                    binding: 1,
121                    resource: wgpu::BindingResource::Sampler(&sampler),
122                },
123            ],
124        });
125
126        self.images.insert(
127            name.to_string(),
128            ImageEntry {
129                _texture: texture,
130                bind_group,
131            },
132        );
133    }
134
135    /// Get the bind group for a registered image.
136    pub fn get_bind_group(&self, name: &str) -> Option<&wgpu::BindGroup> {
137        self.images.get(name).map(|e| &e.bind_group)
138    }
139
140    /// Check if an image is registered.
141    pub fn contains(&self, name: &str) -> bool {
142        self.images.contains_key(name)
143    }
144
145    /// Remove a registered image.
146    pub fn remove(&mut self, name: &str) {
147        self.images.remove(name);
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn image_manager_new_is_empty() {
157        let mgr = ImageManager::new();
158        assert!(!mgr.contains("test"));
159        assert!(mgr.get_bind_group("test").is_none());
160    }
161}