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///
20/// A texture, with its mip chain, stays registered until the renderer that
21/// owns this manager is dropped, which for a window's renderer is when the
22/// window closes. Nothing in the framework calls [`remove`](Self::remove), so
23/// every distinct image name a window has drawn keeps its texture.
24#[derive(Default)]
25pub struct ImageManager {
26    images: HashMap<String, ImageEntry>,
27    #[cfg(debug_assertions)]
28    texture_count_warning: TextureCountWarning,
29}
30
31/// How many image textures one renderer may hold before a debug build warns.
32#[cfg(debug_assertions)]
33const TEXTURE_COUNT_WARNING_THRESHOLD: usize = 256;
34
35/// Decides when a debug build warns that a renderer holds too many image
36/// textures: once, the first time the count goes past
37/// [`TEXTURE_COUNT_WARNING_THRESHOLD`].
38#[cfg(debug_assertions)]
39#[derive(Debug, Default)]
40struct TextureCountWarning {
41    warned: bool,
42}
43
44#[cfg(debug_assertions)]
45impl TextureCountWarning {
46    /// Whether to warn now that the renderer holds `count` textures.
47    fn should_warn(&mut self, count: usize) -> bool {
48        if self.warned || count <= TEXTURE_COUNT_WARNING_THRESHOLD {
49            return false;
50        }
51        self.warned = true;
52        true
53    }
54}
55
56struct ImageEntry {
57    _texture: wgpu::Texture,
58    bind_group: wgpu::BindGroup,
59}
60
61impl ImageManager {
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Register an image by name. Uploads RGBA pixel data and creates a bind group
67    /// compatible with the quad pipeline's bind group layout.
68    #[allow(clippy::too_many_arguments)]
69    pub fn register_image(
70        &mut self,
71        name: &str,
72        width: u32,
73        height: u32,
74        pixels: &[u8],
75        device: &wgpu::Device,
76        queue: &wgpu::Queue,
77        bind_group_layout: &wgpu::BindGroupLayout,
78    ) {
79        if width == 0 || height == 0 {
80            return;
81        }
82
83        // Levels 1..N (level 0 is `pixels`). Built once, at upload.
84        let mips = build_mip_chain(pixels, width, height);
85
86        let texture = device.create_texture(&wgpu::TextureDescriptor {
87            label: Some("image_texture"),
88            size: wgpu::Extent3d {
89                width,
90                height,
91                depth_or_array_layers: 1,
92            },
93            mip_level_count: 1 + mips.len() as u32,
94            sample_count: 1,
95            dimension: wgpu::TextureDimension::D2,
96            format: wgpu::TextureFormat::Rgba8UnormSrgb,
97            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
98            view_formats: &[],
99        });
100
101        // Level 0, then each generated level. A texture declaring mip levels it
102        // never receives samples as transparent black wherever the sampler
103        // reaches them, so every declared level must be written.
104        let upload = |level: u32, w: u32, h: u32, data: &[u8]| {
105            queue.write_texture(
106                wgpu::TexelCopyTextureInfo {
107                    texture: &texture,
108                    mip_level: level,
109                    origin: wgpu::Origin3d::ZERO,
110                    aspect: wgpu::TextureAspect::All,
111                },
112                data,
113                wgpu::TexelCopyBufferLayout {
114                    offset: 0,
115                    bytes_per_row: Some(w * 4),
116                    rows_per_image: Some(h),
117                },
118                wgpu::Extent3d {
119                    width: w,
120                    height: h,
121                    depth_or_array_layers: 1,
122                },
123            );
124        };
125        upload(0, width, height, pixels);
126        for (level, (w, h, data)) in mips.iter().enumerate() {
127            upload(level as u32 + 1, *w, *h, data);
128        }
129
130        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
131        // Trilinear: `mipmap_filter` is what actually engages the chain. Left at
132        // its `Nearest` default, a minified image snaps between whole levels and
133        // visibly pops as the scale crosses a power of two — and with a
134        // single-level texture (the pre-mip behaviour) it would never leave
135        // level 0 at all, which is the aliasing this exists to remove.
136        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
137            mag_filter: wgpu::FilterMode::Linear,
138            min_filter: wgpu::FilterMode::Linear,
139            mipmap_filter: wgpu::MipmapFilterMode::Linear,
140            ..Default::default()
141        });
142
143        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
144            label: Some("image_bind_group"),
145            layout: bind_group_layout,
146            entries: &[
147                wgpu::BindGroupEntry {
148                    binding: 0,
149                    resource: wgpu::BindingResource::TextureView(&view),
150                },
151                wgpu::BindGroupEntry {
152                    binding: 1,
153                    resource: wgpu::BindingResource::Sampler(&sampler),
154                },
155            ],
156        });
157
158        self.images.insert(
159            name.to_string(),
160            ImageEntry {
161                _texture: texture,
162                bind_group,
163            },
164        );
165
166        #[cfg(debug_assertions)]
167        if self.texture_count_warning.should_warn(self.images.len()) {
168            eprintln!(
169                "teksilo-render: this window holds {} image textures and frees none before it \
170                 closes, because every decoded RasterIcon and every ImageWidget::from_raw gets a \
171                 texture of its own; decode each image once and clone the RasterIcon.",
172                self.images.len()
173            );
174        }
175    }
176
177    /// Get the bind group for a registered image.
178    pub fn get_bind_group(&self, name: &str) -> Option<&wgpu::BindGroup> {
179        self.images.get(name).map(|e| &e.bind_group)
180    }
181
182    /// Check if an image is registered.
183    pub fn contains(&self, name: &str) -> bool {
184        self.images.contains_key(name)
185    }
186
187    /// Remove a registered image.
188    pub fn remove(&mut self, name: &str) {
189        self.images.remove(name);
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn image_manager_new_is_empty() {
199        let mgr = ImageManager::new();
200        assert!(!mgr.contains("test"));
201        assert!(mgr.get_bind_group("test").is_none());
202    }
203
204    #[cfg(debug_assertions)]
205    #[test]
206    fn texture_count_warning_fires_once_past_the_threshold() {
207        let mut warning = TextureCountWarning::default();
208        for count in 0..=TEXTURE_COUNT_WARNING_THRESHOLD {
209            assert!(!warning.should_warn(count), "warned at {count}");
210        }
211        assert!(
212            warning.should_warn(TEXTURE_COUNT_WARNING_THRESHOLD + 1),
213            "must warn as the count first goes past the threshold"
214        );
215        for count in [
216            TEXTURE_COUNT_WARNING_THRESHOLD + 1,
217            TEXTURE_COUNT_WARNING_THRESHOLD + 2,
218            TEXTURE_COUNT_WARNING_THRESHOLD,
219            10 * TEXTURE_COUNT_WARNING_THRESHOLD,
220        ] {
221            assert!(
222                !warning.should_warn(count),
223                "warned a second time at {count}"
224            );
225        }
226    }
227}