Skip to main content

pebble/wgpu/
mipmap.rs

1use std::collections::HashMap;
2
3use crate::{assets::singleton_asset::LazyResource, wgpu::backend::WGPUBackend};
4
5const MIP_SHADER: &str = r#"
6struct VOut {
7    @builtin(position) pos: vec4<f32>,
8    @location(0) uv: vec2<f32>,
9};
10
11@vertex
12fn vs_main(@builtin(vertex_index) idx: u32) -> VOut {
13    var out: VOut;
14    let x = f32((idx << 1u) & 2u);
15    let y = f32(idx & 2u);
16    out.pos = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
17    out.uv = vec2<f32>(x, y);
18    return out;
19}
20
21@group(0) @binding(0) var src_texture: texture_2d<f32>;
22@group(0) @binding(1) var src_sampler: sampler;
23
24@fragment
25fn fs_main(in: VOut) -> @location(0) vec4<f32> {
26    return textureSample(src_texture, src_sampler, in.uv);
27}
28"#;
29
30/// Formats [`MipmapGenerator`] knows how to downsample — matches the formats
31/// `GPUTexture` can decode into (see `wgpu::textures::bytes_per_pixel`).
32const SUPPORTED_FORMATS: &[wgpu::TextureFormat] = &[
33    wgpu::TextureFormat::Rgba8Unorm,
34    wgpu::TextureFormat::Rgba8UnormSrgb,
35    wgpu::TextureFormat::Rgba16Float,
36    wgpu::TextureFormat::Rgba32Float,
37];
38
39/// Number of mip levels a texture with the given largest dimension needs
40/// for a full chain (1 if `generate_mips` is false). Shared by every
41/// texture-like upload (`GPUTexture`/`GPUCubemap`/`GPUTextureArray`) so the
42/// formula — and its zero-dimension edge case (a saturating float→int cast
43/// to 0, `+ 1` gives back a "chain" of 1) — is written once.
44pub(crate) fn mip_count(max_dimension: u32, generate_mips: bool) -> u32 {
45    if generate_mips {
46        (max_dimension as f32).log2().floor() as u32 + 1
47    } else {
48        1
49    }
50}
51
52/// Usage flags for a texture with `mip_count` levels: mips beyond level 0
53/// are rendered into on the GPU (via [`MipmapGenerator::generate_mips`]),
54/// so a texture with more than one level needs `RENDER_ATTACHMENT` in
55/// addition to the usual sampling/upload usages.
56pub(crate) fn texture_usage(mip_count: u32) -> wgpu::TextureUsages {
57    if mip_count > 1 {
58        wgpu::TextureUsages::TEXTURE_BINDING
59            | wgpu::TextureUsages::COPY_DST
60            | wgpu::TextureUsages::RENDER_ATTACHMENT
61    } else {
62        wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST
63    }
64}
65
66struct MipPipeline {
67    pipeline: wgpu::RenderPipeline,
68    filterable: bool,
69}
70
71/// GPU-side mipmap generator: a shared render pipeline (per texture format)
72/// that downsamples one mip level into the next via a fullscreen-triangle
73/// blit, plus the bind group layouts/samplers it needs.
74///
75/// Built once as a [`LazyResource`] and reused by every [`GPUTexture`](crate::wgpu::textures::GPUTexture)
76/// upload that requests mips.
77pub struct MipmapGenerator {
78    filtering_layout: wgpu::BindGroupLayout,
79    nonfiltering_layout: wgpu::BindGroupLayout,
80    linear_sampler: wgpu::Sampler,
81    nearest_sampler: wgpu::Sampler,
82    pipelines: HashMap<wgpu::TextureFormat, MipPipeline>,
83}
84
85impl MipmapGenerator {
86    fn bind_group_layout(device: &wgpu::Device, filterable: bool) -> wgpu::BindGroupLayout {
87        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
88            label: Some("mipmap-blit-bind-group-layout"),
89            entries: &[
90                wgpu::BindGroupLayoutEntry {
91                    binding: 0,
92                    visibility: wgpu::ShaderStages::FRAGMENT,
93                    ty: wgpu::BindingType::Texture {
94                        sample_type: wgpu::TextureSampleType::Float { filterable },
95                        view_dimension: wgpu::TextureViewDimension::D2,
96                        multisampled: false,
97                    },
98                    count: None,
99                },
100                wgpu::BindGroupLayoutEntry {
101                    binding: 1,
102                    visibility: wgpu::ShaderStages::FRAGMENT,
103                    ty: wgpu::BindingType::Sampler(if filterable {
104                        wgpu::SamplerBindingType::Filtering
105                    } else {
106                        wgpu::SamplerBindingType::NonFiltering
107                    }),
108                    count: None,
109                },
110            ],
111        })
112    }
113
114    fn build_pipeline(
115        device: &wgpu::Device,
116        module: &wgpu::ShaderModule,
117        layout: &wgpu::BindGroupLayout,
118        format: wgpu::TextureFormat,
119    ) -> wgpu::RenderPipeline {
120        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
121            label: Some("mipmap-blit-pipeline-layout"),
122            bind_group_layouts: &[Some(layout)],
123            immediate_size: 0,
124        });
125
126        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
127            label: Some("mipmap-blit-pipeline"),
128            layout: Some(&pipeline_layout),
129            vertex: wgpu::VertexState {
130                module,
131                entry_point: Some("vs_main"),
132                compilation_options: Default::default(),
133                buffers: &[],
134            },
135            primitive: wgpu::PrimitiveState {
136                topology: wgpu::PrimitiveTopology::TriangleList,
137                strip_index_format: None,
138                front_face: wgpu::FrontFace::Ccw,
139                cull_mode: None,
140                unclipped_depth: false,
141                polygon_mode: wgpu::PolygonMode::Fill,
142                conservative: false,
143            },
144            depth_stencil: None,
145            multisample: wgpu::MultisampleState::default(),
146            fragment: Some(wgpu::FragmentState {
147                module,
148                entry_point: Some("fs_main"),
149                compilation_options: Default::default(),
150                targets: &[Some(wgpu::ColorTargetState {
151                    format,
152                    blend: None,
153                    write_mask: wgpu::ColorWrites::ALL,
154                })],
155            }),
156            multiview_mask: None,
157            cache: None,
158        })
159    }
160
161    fn new(device: &wgpu::Device) -> Self {
162        let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
163            label: Some("mipmap-blit-shader"),
164            source: wgpu::ShaderSource::Wgsl(MIP_SHADER.into()),
165        });
166
167        let filtering_layout = Self::bind_group_layout(device, true);
168        let nonfiltering_layout = Self::bind_group_layout(device, false);
169
170        let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
171            label: Some("mipmap-linear-sampler"),
172            mag_filter: wgpu::FilterMode::Linear,
173            min_filter: wgpu::FilterMode::Linear,
174            ..Default::default()
175        });
176        let nearest_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
177            label: Some("mipmap-nearest-sampler"),
178            mag_filter: wgpu::FilterMode::Nearest,
179            min_filter: wgpu::FilterMode::Nearest,
180            ..Default::default()
181        });
182
183        let mut pipelines = HashMap::new();
184        for &format in SUPPORTED_FORMATS {
185            // Rgba32Float has no filterable-sampling support without an extra
186            // device feature, so it gets a nearest-sampled, non-filtering path.
187            let filterable = format != wgpu::TextureFormat::Rgba32Float;
188            let layout = if filterable {
189                &filtering_layout
190            } else {
191                &nonfiltering_layout
192            };
193            let pipeline = Self::build_pipeline(device, &module, layout, format);
194            pipelines.insert(format, MipPipeline { pipeline, filterable });
195        }
196
197        Self {
198            filtering_layout,
199            nonfiltering_layout,
200            linear_sampler,
201            nearest_sampler,
202            pipelines,
203        }
204    }
205
206    /// Downsamples `texture` (already holding valid data at mip level 0 of
207    /// every layer) into its remaining `mip_count - 1` levels, entirely on
208    /// the GPU. `layer_count` is 1 for a plain 2D texture, N for a texture
209    /// array, or 6 for a cubemap — each array layer/face is downsampled
210    /// independently via its own single-layer 2D view.
211    pub fn generate_mips(
212        &self,
213        device: &wgpu::Device,
214        queue: &wgpu::Queue,
215        texture: &wgpu::Texture,
216        format: wgpu::TextureFormat,
217        mip_count: u32,
218        layer_count: u32,
219    ) {
220        if mip_count <= 1 {
221            return;
222        }
223
224        let Some(entry) = self.pipelines.get(&format) else {
225            tracing::error!("MipmapGenerator: no blit pipeline for format {format:?}, skipping mip generation");
226            return;
227        };
228        let layout = if entry.filterable {
229            &self.filtering_layout
230        } else {
231            &self.nonfiltering_layout
232        };
233        let sampler = if entry.filterable {
234            &self.linear_sampler
235        } else {
236            &self.nearest_sampler
237        };
238
239        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
240            label: Some("mipmap-blit-encoder"),
241        });
242
243        for layer in 0..layer_count {
244            for level in 1..mip_count {
245                let src_view = texture.create_view(&wgpu::TextureViewDescriptor {
246                    dimension: Some(wgpu::TextureViewDimension::D2),
247                    base_mip_level: level - 1,
248                    mip_level_count: Some(1),
249                    base_array_layer: layer,
250                    array_layer_count: Some(1),
251                    ..Default::default()
252                });
253                let dst_view = texture.create_view(&wgpu::TextureViewDescriptor {
254                    dimension: Some(wgpu::TextureViewDimension::D2),
255                    base_mip_level: level,
256                    mip_level_count: Some(1),
257                    base_array_layer: layer,
258                    array_layer_count: Some(1),
259                    ..Default::default()
260                });
261
262                let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
263                    label: Some("mipmap-blit-bind-group"),
264                    layout,
265                    entries: &[
266                        wgpu::BindGroupEntry {
267                            binding: 0,
268                            resource: wgpu::BindingResource::TextureView(&src_view),
269                        },
270                        wgpu::BindGroupEntry {
271                            binding: 1,
272                            resource: wgpu::BindingResource::Sampler(sampler),
273                        },
274                    ],
275                });
276
277                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
278                    label: Some("mipmap-blit-pass"),
279                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
280                        view: &dst_view,
281                        depth_slice: None,
282                        resolve_target: None,
283                        ops: wgpu::Operations {
284                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
285                            store: wgpu::StoreOp::Store,
286                        },
287                    })],
288                    depth_stencil_attachment: None,
289                    timestamp_writes: None,
290                    occlusion_query_set: None,
291                    multiview_mask: None,
292                });
293                pass.set_pipeline(&entry.pipeline);
294                pass.set_bind_group(0, &bind_group, &[]);
295                pass.draw(0..3, 0..1);
296                drop(pass);
297            }
298        }
299
300        queue.submit(std::iter::once(encoder.finish()));
301    }
302}
303
304impl LazyResource<WGPUBackend> for MipmapGenerator {
305    type Deps<'a> = ();
306
307    fn construct<'a>(backend: &WGPUBackend, _deps: &()) -> Option<Self> {
308        Some(Self::new(&backend.device))
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn mip_count_is_one_when_mips_are_not_requested() {
318        assert_eq!(mip_count(2048, false), 1);
319        assert_eq!(mip_count(1, false), 1);
320        assert_eq!(mip_count(0, false), 1);
321    }
322
323    #[test]
324    fn mip_count_matches_a_full_power_of_two_chain() {
325        // A 1x1 texture needs exactly one level; each doubling adds exactly one more.
326        assert_eq!(mip_count(1, true), 1);
327        assert_eq!(mip_count(2, true), 2);
328        assert_eq!(mip_count(4, true), 3);
329        assert_eq!(mip_count(256, true), 9);
330        assert_eq!(mip_count(1024, true), 11);
331    }
332
333    #[test]
334    fn mip_count_floors_a_non_power_of_two_dimension() {
335        // 300's largest power-of-two factor is 256 (2^8), so the chain is as
336        // long as a 256-sized texture's, not rounded up to 512's.
337        assert_eq!(mip_count(300, true), 9);
338    }
339
340    #[test]
341    fn mip_count_of_a_zero_dimension_does_not_panic_or_underflow() {
342        // log2(0) is -infinity; the `as u32` cast saturates to 0 rather than
343        // panicking or wrapping, so this degenerates to a "chain" of 1
344        // instead of corrupting `mip_level_count` on the eventual
345        // TextureDescriptor (which must be >= 1 or wgpu panics).
346        assert_eq!(mip_count(0, true), 1);
347    }
348
349    #[test]
350    fn texture_usage_adds_render_attachment_only_when_there_is_more_than_one_mip() {
351        let single = texture_usage(1);
352        assert!(!single.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
353        assert!(single.contains(wgpu::TextureUsages::TEXTURE_BINDING));
354        assert!(single.contains(wgpu::TextureUsages::COPY_DST));
355
356        let chained = texture_usage(5);
357        assert!(chained.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
358        assert!(chained.contains(wgpu::TextureUsages::TEXTURE_BINDING));
359        assert!(chained.contains(wgpu::TextureUsages::COPY_DST));
360    }
361}