Skip to main content

valo_renderer/
images.rs

1use std::collections::HashMap;
2use std::sync::Weak;
3
4use valo_dl::{BlendMode, ColorFilter, Filter, Image, ImageInner, MipmapMode, Sampling, TileMode};
5
6/// `ImageDesc` describes an RGBA8 image upload.
7#[derive(Clone, Copy, Debug)]
8pub struct ImageDesc {
9    /// `size` is the image dimensions in pixels.
10    pub size: [u32; 2],
11    /// `premultiplied` indicates whether the supplied RGB channels already
12    /// contain alpha multiplication.
13    ///
14    /// When `false`, Valo premultiplies them during upload.
15    pub premultiplied: bool,
16    /// `mips` controls whether Valo builds a full mip chain.
17    ///
18    /// Enable it when the image may be drawn smaller than its source size.
19    pub mips: bool,
20}
21
22impl Default for ImageDesc {
23    fn default() -> Self {
24        Self {
25            size: [0, 0],
26            premultiplied: false,
27            mips: true,
28        }
29    }
30}
31
32/// `ImageStore` owns uploaded images, mip generation, samplers, and bind groups.
33///
34/// Bind groups are created once per (image, sampling) pair and reused. Dead
35/// images are swept via `Weak` so the store does not pin host-dropped images.
36pub struct ImageStore {
37    device: wgpu::Device,
38    queue: wgpu::Queue,
39    samplers: HashMap<Sampling, wgpu::Sampler>,
40    binds: HashMap<(u64, Sampling), (Weak<ImageInner>, wgpu::BindGroup)>,
41    filtered: HashMap<(u64, ColorFilterKey), FilteredImage>,
42    frame: u64,
43    mips: MipGenerator,
44}
45
46/// `IMAGE_FORMAT` is the GPU format of every uploaded image texture.
47pub const IMAGE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
48
49impl ImageStore {
50    /// `new` creates an empty image store for `device` and `queue`.
51    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
52        Self {
53            device: device.clone(),
54            queue: queue.clone(),
55            samplers: HashMap::new(),
56            binds: HashMap::new(),
57            filtered: HashMap::new(),
58            frame: 0,
59            mips: MipGenerator::new(device),
60        }
61    }
62
63    /// `filtered_image` returns the immutable texture that represents `image` after `filter`.
64    ///
65    /// The second return value is `true` when this call created the texture.
66    /// The caller records the producing pass only in that case.
67    pub fn filtered_image(&mut self, image: &Image, filter: ColorFilter) -> (Image, bool) {
68        self.sweep_if_crowded();
69        let key = (image.id(), ColorFilterKey::from(filter));
70        if let Some(entry) = self.filtered.get_mut(&key) {
71            entry.last_used = self.frame;
72            return (entry.image.clone(), false);
73        }
74        let texture = self.create_image_texture(image.size(), 1);
75        let filtered = Image::from_texture(texture, image.size(), 1);
76        self.filtered.insert(
77            key,
78            FilteredImage {
79                source: image.downgrade(),
80                image: filtered.clone(),
81                last_used: self.frame,
82            },
83        );
84        (filtered, true)
85    }
86
87    /// `end_frame` drops filtered snapshots unused this frame.
88    ///
89    /// One idle frame releases a filtered snapshot even when the host keeps
90    /// its source image alive, bounding retention to the visible working set.
91    pub fn end_frame(&mut self) {
92        let current = self.frame;
93        self.frame += 1;
94        let before = self.filtered.len();
95        self.filtered
96            .retain(|_, entry| entry.last_used >= current && entry.source.strong_count() > 0);
97        if self.filtered.len() != before {
98            // Bind groups retain texture views. Drop dead ones now so cache
99            // eviction releases the corresponding GPU textures promptly.
100            self.binds.retain(|_, (weak, _)| weak.strong_count() > 0);
101        }
102    }
103
104    /// `upload` creates a retained [`Image`] from RGBA8 pixels.
105    ///
106    /// Premultiplies when `desc.premultiplied` is false, writes mip level 0,
107    /// and builds the mip chain when `desc.mips` is true. Panics if
108    /// `pixels.len()` is not `width * height * 4`.
109    pub fn upload(&mut self, desc: ImageDesc, pixels: &[u8]) -> Image {
110        let [w, h] = desc.size;
111        assert_eq!(pixels.len(), (w * h * 4) as usize, "RGBA8 pixel count");
112        let premul = premultiplied_pixels(desc.premultiplied, pixels);
113        let mip_levels = if desc.mips {
114            full_mip_count(desc.size)
115        } else {
116            1
117        };
118        let texture = self.create_image_texture(desc.size, mip_levels);
119        self.write_level_zero(&texture, desc.size, &premul);
120        if mip_levels > 1 {
121            self.mips
122                .generate(&self.device, &self.queue, &texture, desc.size, mip_levels);
123        }
124        Image::from_texture(texture, desc.size, mip_levels)
125    }
126
127    /// `finish_external` wraps an already-populated texture as a retained [`Image`].
128    ///
129    /// Use this when the host copied pixels itself (for example an
130    /// `ImageBitmap` upload). Builds the mip chain when `mip_levels` is
131    /// greater than 1.
132    pub fn finish_external(
133        &mut self,
134        texture: wgpu::Texture,
135        size: [u32; 2],
136        mip_levels: u32,
137    ) -> Image {
138        if mip_levels > 1 {
139            self.mips
140                .generate(&self.device, &self.queue, &texture, size, mip_levels);
141        }
142        Image::from_texture(texture, size, mip_levels)
143    }
144
145    /// `regenerate_mips` rebuilds the mip chain after level 0 was rewritten in place.
146    ///
147    /// Call this after each copy from a per-frame source such as a video frame.
148    pub fn regenerate_mips(&mut self, image: &Image) {
149        if image.mip_levels() > 1 {
150            self.mips.generate(
151                &self.device,
152                &self.queue,
153                image.texture(),
154                image.size(),
155                image.mip_levels(),
156            );
157        }
158    }
159
160    /// `create_image_texture` allocates an empty image texture of `size` and `mip_levels`.
161    ///
162    /// The texture is bindable, copy-destination, and a render attachment so
163    /// mip levels can be generated by rendering into them.
164    pub fn create_image_texture(&self, size: [u32; 2], mip_levels: u32) -> wgpu::Texture {
165        self.device.create_texture(&wgpu::TextureDescriptor {
166            label: Some("valo.image"),
167            size: wgpu::Extent3d {
168                width: size[0],
169                height: size[1],
170                depth_or_array_layers: 1,
171            },
172            mip_level_count: mip_levels,
173            sample_count: 1,
174            dimension: wgpu::TextureDimension::D2,
175            format: IMAGE_FORMAT,
176            // RENDER_ATTACHMENT: mip levels are generated by rendering into them.
177            usage: wgpu::TextureUsages::TEXTURE_BINDING
178                | wgpu::TextureUsages::COPY_DST
179                | wgpu::TextureUsages::RENDER_ATTACHMENT,
180            view_formats: &[],
181        })
182    }
183
184    /// `bind_group` returns the cached (texture, sampler) bind group for a draw.
185    pub fn bind_group(
186        &mut self,
187        texture_layout: &wgpu::BindGroupLayout,
188        image: &Image,
189        sampling: Sampling,
190    ) -> wgpu::BindGroup {
191        self.sweep_if_crowded();
192        let key = (image.id(), sampling);
193        if let Some((_, bind)) = self.binds.get(&key) {
194            return bind.clone();
195        }
196        let sampler = self.sampler(sampling).clone();
197        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
198            label: Some("valo.image"),
199            layout: texture_layout,
200            entries: &[
201                wgpu::BindGroupEntry {
202                    binding: 0,
203                    resource: wgpu::BindingResource::TextureView(image.view()),
204                },
205                wgpu::BindGroupEntry {
206                    binding: 1,
207                    resource: wgpu::BindingResource::Sampler(&sampler),
208                },
209            ],
210        });
211        self.binds.insert(key, (image.downgrade(), bind.clone()));
212        bind
213    }
214
215    fn sampler(&mut self, sampling: Sampling) -> &wgpu::Sampler {
216        self.samplers.entry(sampling).or_insert_with(|| {
217            let filter = match sampling.filter {
218                Filter::Linear => wgpu::FilterMode::Linear,
219                Filter::Nearest => wgpu::FilterMode::Nearest,
220            };
221            let mip_filter = match sampling.mipmap {
222                MipmapMode::Linear => wgpu::MipmapFilterMode::Linear,
223                MipmapMode::None | MipmapMode::Nearest => wgpu::MipmapFilterMode::Nearest,
224            };
225            // `None` is a LOD clamp rather than a filter mode: WebGPU has no
226            // "ignore the chain" switch, so pinning the max LOD to level 0 is
227            // how a sampler is told to stay sharp.
228            let max_lod = match sampling.mipmap {
229                MipmapMode::None => 0.0,
230                _ => 32.0,
231            };
232            self.device.create_sampler(&wgpu::SamplerDescriptor {
233                label: Some("valo.image"),
234                address_mode_u: address_mode(sampling.tile_x),
235                address_mode_v: address_mode(sampling.tile_y),
236                mag_filter: filter,
237                min_filter: filter,
238                mipmap_filter: mip_filter,
239                lod_max_clamp: max_lod,
240                ..Default::default()
241            })
242        })
243    }
244
245    fn write_level_zero(&self, texture: &wgpu::Texture, size: [u32; 2], premul: &[u8]) {
246        self.queue.write_texture(
247            wgpu::TexelCopyTextureInfo {
248                texture,
249                mip_level: 0,
250                origin: wgpu::Origin3d::ZERO,
251                aspect: wgpu::TextureAspect::All,
252            },
253            premul,
254            wgpu::TexelCopyBufferLayout {
255                offset: 0,
256                bytes_per_row: Some(size[0] * 4),
257                rows_per_image: None,
258            },
259            wgpu::Extent3d {
260                width: size[0],
261                height: size[1],
262                depth_or_array_layers: 1,
263            },
264        );
265    }
266
267    /// Bind groups whose image died are dropped; runs only when the cache
268    /// grows past a threshold (posters hold tens of images, not thousands).
269    fn sweep_if_crowded(&mut self) {
270        if self.binds.len() > 256 {
271            self.binds.retain(|_, (weak, _)| weak.strong_count() > 0);
272        }
273    }
274}
275
276struct FilteredImage {
277    source: Weak<ImageInner>,
278    image: Image,
279    last_used: u64,
280}
281
282#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
283enum ColorFilterKey {
284    Matrix([u32; 20]),
285    Blend([u32; 4], BlendMode),
286}
287
288impl From<ColorFilter> for ColorFilterKey {
289    fn from(filter: ColorFilter) -> Self {
290        match filter {
291            ColorFilter::Matrix(matrix) => Self::Matrix(matrix.map(f32::to_bits)),
292            ColorFilter::Blend(color, mode) => Self::Blend(
293                [
294                    color.r.to_bits(),
295                    color.g.to_bits(),
296                    color.b.to_bits(),
297                    color.a.to_bits(),
298                ],
299                mode,
300            ),
301        }
302    }
303}
304
305fn address_mode(tile: TileMode) -> wgpu::AddressMode {
306    match tile {
307        // Decal clamps at the sampler and cuts off in the shader: WebGPU has
308        // no transparent border colour (`ADDRESS_MODE_CLAMP_TO_BORDER` is not
309        // in the baseline), so the alternative would be a feature the web
310        // target cannot have.
311        TileMode::Clamp | TileMode::Decal => wgpu::AddressMode::ClampToEdge,
312        TileMode::Repeat => wgpu::AddressMode::Repeat,
313        TileMode::Mirror => wgpu::AddressMode::MirrorRepeat,
314    }
315}
316
317fn premultiplied_pixels(already: bool, pixels: &[u8]) -> std::borrow::Cow<'_, [u8]> {
318    if already {
319        return std::borrow::Cow::Borrowed(pixels);
320    }
321    let mut out = pixels.to_vec();
322    for px in out.chunks_exact_mut(4) {
323        let a = px[3] as u32;
324        px[0] = ((px[0] as u32 * a) / 255) as u8;
325        px[1] = ((px[1] as u32 * a) / 255) as u8;
326        px[2] = ((px[2] as u32 * a) / 255) as u8;
327    }
328    std::borrow::Cow::Owned(out)
329}
330
331fn full_mip_count(size: [u32; 2]) -> u32 {
332    32 - size[0].max(size[1]).max(1).leading_zeros()
333}
334
335/// Renders each mip level from the one above (fullscreen triangle + linear
336/// sample). Runs once per upload — never per frame.
337struct MipGenerator {
338    pipeline: wgpu::RenderPipeline,
339    layout: wgpu::BindGroupLayout,
340    sampler: wgpu::Sampler,
341}
342
343const MIP_SHADER: &str = r#"
344struct VsOut { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32> };
345@vertex fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
346    // Fullscreen triangle.
347    let xy = vec2<f32>(f32((vi << 1u) & 2u), f32(vi & 2u));
348    var out: VsOut;
349    out.pos = vec4<f32>(xy * 2.0 - 1.0, 0.0, 1.0);
350    out.uv = vec2<f32>(xy.x, 1.0 - xy.y);
351    return out;
352}
353@group(0) @binding(0) var t: texture_2d<f32>;
354@group(0) @binding(1) var s: sampler;
355@fragment fn fs(in: VsOut) -> @location(0) vec4<f32> {
356    return textureSample(t, s, in.uv);
357}
358"#;
359
360impl MipGenerator {
361    fn new(device: &wgpu::Device) -> Self {
362        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
363            label: Some("valo.mips"),
364            source: wgpu::ShaderSource::Wgsl(MIP_SHADER.into()),
365        });
366        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
367            label: Some("valo.mips"),
368            entries: &[
369                wgpu::BindGroupLayoutEntry {
370                    binding: 0,
371                    visibility: wgpu::ShaderStages::FRAGMENT,
372                    ty: wgpu::BindingType::Texture {
373                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
374                        view_dimension: wgpu::TextureViewDimension::D2,
375                        multisampled: false,
376                    },
377                    count: None,
378                },
379                wgpu::BindGroupLayoutEntry {
380                    binding: 1,
381                    visibility: wgpu::ShaderStages::FRAGMENT,
382                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
383                    count: None,
384                },
385            ],
386        });
387        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
388            label: Some("valo.mips"),
389            bind_group_layouts: &[Some(&layout)],
390            immediate_size: 0,
391        });
392        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
393            label: Some("valo.mips"),
394            layout: Some(&pipeline_layout),
395            vertex: wgpu::VertexState {
396                module: &shader,
397                entry_point: Some("vs"),
398                compilation_options: Default::default(),
399                buffers: &[],
400            },
401            fragment: Some(wgpu::FragmentState {
402                module: &shader,
403                entry_point: Some("fs"),
404                compilation_options: Default::default(),
405                targets: &[Some(wgpu::ColorTargetState {
406                    format: IMAGE_FORMAT,
407                    blend: None,
408                    write_mask: wgpu::ColorWrites::ALL,
409                })],
410            }),
411            primitive: wgpu::PrimitiveState::default(),
412            depth_stencil: None,
413            multisample: wgpu::MultisampleState::default(),
414            multiview_mask: None,
415            cache: None,
416        });
417        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
418            label: Some("valo.mips"),
419            mag_filter: wgpu::FilterMode::Linear,
420            min_filter: wgpu::FilterMode::Linear,
421            ..Default::default()
422        });
423        Self {
424            pipeline,
425            layout,
426            sampler,
427        }
428    }
429
430    fn generate(
431        &self,
432        device: &wgpu::Device,
433        queue: &wgpu::Queue,
434        texture: &wgpu::Texture,
435        _size: [u32; 2],
436        mip_levels: u32,
437    ) {
438        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
439            label: Some("valo.mips"),
440        });
441        for level in 1..mip_levels {
442            let (src, dst) = (
443                self.level_view(texture, level - 1),
444                self.level_view(texture, level),
445            );
446            let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
447                label: Some("valo.mips"),
448                layout: &self.layout,
449                entries: &[
450                    wgpu::BindGroupEntry {
451                        binding: 0,
452                        resource: wgpu::BindingResource::TextureView(&src),
453                    },
454                    wgpu::BindGroupEntry {
455                        binding: 1,
456                        resource: wgpu::BindingResource::Sampler(&self.sampler),
457                    },
458                ],
459            });
460            self.blit_level(&mut encoder, &dst, &bind);
461        }
462        queue.submit(std::iter::once(encoder.finish()));
463    }
464
465    fn level_view(&self, texture: &wgpu::Texture, level: u32) -> wgpu::TextureView {
466        texture.create_view(&wgpu::TextureViewDescriptor {
467            base_mip_level: level,
468            mip_level_count: Some(1),
469            ..Default::default()
470        })
471    }
472
473    fn blit_level(
474        &self,
475        encoder: &mut wgpu::CommandEncoder,
476        dst: &wgpu::TextureView,
477        bind: &wgpu::BindGroup,
478    ) {
479        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
480            label: Some("valo.mips"),
481            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
482                view: dst,
483                depth_slice: None,
484                resolve_target: None,
485                ops: wgpu::Operations {
486                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
487                    store: wgpu::StoreOp::Store,
488                },
489            })],
490            depth_stencil_attachment: None,
491            timestamp_writes: None,
492            occlusion_query_set: None,
493            multiview_mask: None,
494        });
495        pass.set_pipeline(&self.pipeline);
496        pass.set_bind_group(0, bind, &[]);
497        pass.draw(0..3, 0..1);
498    }
499}
500
501impl ImageStore {
502    /// Live uploaded images, deduped across sampler variants; bytes cover
503    /// the mip chain (a full chain adds ~1/3).
504    pub(crate) fn report(&self) -> crate::PoolReport {
505        let mut seen = std::collections::HashSet::new();
506        let mut bytes = 0u64;
507        for (weak, _) in self.binds.values() {
508            let Some(inner) = weak.upgrade() else {
509                continue;
510            };
511            if !seen.insert(inner.id) {
512                continue;
513            }
514            let base = inner.size[0] as u64 * inner.size[1] as u64 * 4;
515            bytes += if inner.mip_levels > 1 {
516                base * 4 / 3
517            } else {
518                base
519            };
520        }
521        crate::PoolReport {
522            count: seen.len() as u32,
523            bytes,
524        }
525    }
526}