Skip to main content

pebble/graphics/pipeline/
mipmap.rs

1use std::collections::HashMap;
2
3use crate::{
4    ecs::{commands::Commands, resources::Read},
5    graphics::render::Backend,
6};
7
8const MIP_SHADER: &str = r#"
9struct VOut {
10    @builtin(position) pos: vec4<f32>,
11    @location(0) uv: vec2<f32>,
12};
13
14@vertex
15fn vs_main(@builtin(vertex_index) idx: u32) -> VOut {
16    var out: VOut;
17    let x = f32((idx << 1u) & 2u);
18    let y = f32(idx & 2u);
19    out.pos = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
20    out.uv = vec2<f32>(x, y);
21    return out;
22}
23
24@group(0) @binding(0) var src_texture: texture_2d<f32>;
25@group(0) @binding(1) var src_sampler: sampler;
26
27@fragment
28fn fs_main(in: VOut) -> @location(0) vec4<f32> {
29    return textureSample(src_texture, src_sampler, in.uv);
30}
31"#;
32
33const SUPPORTED_FORMATS: &[wgpu::TextureFormat] = &[
34    wgpu::TextureFormat::Rgba8Unorm,
35    wgpu::TextureFormat::Rgba8UnormSrgb,
36    wgpu::TextureFormat::Rgba16Float,
37    wgpu::TextureFormat::Rgba32Float,
38];
39
40#[derive(Default, Clone, Copy)]
41pub(crate) enum MipLevels {
42    #[default]
43    None,
44    Full,
45    Fixed(u32),
46}
47
48pub(crate) fn mip_count(max_dimension: u32, requested: MipLevels) -> u32 {
49    let full = (max_dimension as f32).log2().floor() as u32 + 1;
50    match requested {
51        MipLevels::None => 1,
52        MipLevels::Full => full,
53        MipLevels::Fixed(count) => count.clamp(1, full),
54    }
55}
56
57pub(crate) fn texture_usage(mip_count: u32) -> wgpu::TextureUsages {
58    if mip_count > 1 {
59        wgpu::TextureUsages::TEXTURE_BINDING
60            | wgpu::TextureUsages::COPY_DST
61            | wgpu::TextureUsages::RENDER_ATTACHMENT
62    } else {
63        wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST
64    }
65}
66
67// RENDER_ATTACHMENT is otherwise only added when mip_count > 1, for the
68// internal mip-blit pass — that has nothing to do with whether the caller
69// wants to render into this texture themselves. No source data usually
70// means exactly that (e.g. a post-processing intermediate target), so it
71// gets RENDER_ATTACHMENT regardless of mip count.
72pub(crate) fn texture_usage_for(mip_count: u32, has_data: bool) -> wgpu::TextureUsages {
73    let usage = texture_usage(mip_count);
74    if has_data { usage } else { usage | wgpu::TextureUsages::RENDER_ATTACHMENT }
75}
76
77struct MipPipeline {
78    pipeline: wgpu::RenderPipeline,
79    filterable: bool,
80}
81
82/// GPU-side mip chain generator (a blit shader, run once per level) —
83/// inserted as a resource by [`BuiltinAssetsPlugin`](crate::graphics::BuiltinAssetsPlugin).
84/// Used internally by texture uploads when `with_mips()`/`with_mip_count()` is set.
85pub struct MipmapGenerator {
86    filtering_layout: wgpu::BindGroupLayout,
87    nonfiltering_layout: wgpu::BindGroupLayout,
88    linear_sampler: wgpu::Sampler,
89    nearest_sampler: wgpu::Sampler,
90    pipelines: HashMap<wgpu::TextureFormat, MipPipeline>,
91}
92
93impl MipmapGenerator {
94    fn bind_group_layout(device: &wgpu::Device, filterable: bool) -> wgpu::BindGroupLayout {
95        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
96            label: Some("mipmap-blit-bind-group-layout"),
97            entries: &[
98                wgpu::BindGroupLayoutEntry {
99                    binding: 0,
100                    visibility: wgpu::ShaderStages::FRAGMENT,
101                    ty: wgpu::BindingType::Texture {
102                        sample_type: wgpu::TextureSampleType::Float { filterable },
103                        view_dimension: wgpu::TextureViewDimension::D2,
104                        multisampled: false,
105                    },
106                    count: None,
107                },
108                wgpu::BindGroupLayoutEntry {
109                    binding: 1,
110                    visibility: wgpu::ShaderStages::FRAGMENT,
111                    ty: wgpu::BindingType::Sampler(if filterable {
112                        wgpu::SamplerBindingType::Filtering
113                    } else {
114                        wgpu::SamplerBindingType::NonFiltering
115                    }),
116                    count: None,
117                },
118            ],
119        })
120    }
121
122    fn build_pipeline(
123        device: &wgpu::Device,
124        module: &wgpu::ShaderModule,
125        layout: &wgpu::BindGroupLayout,
126        format: wgpu::TextureFormat,
127    ) -> wgpu::RenderPipeline {
128        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
129            label: Some("mipmap-blit-pipeline-layout"),
130            bind_group_layouts: &[Some(layout)],
131            immediate_size: 0,
132        });
133
134        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
135            label: Some("mipmap-blit-pipeline"),
136            layout: Some(&pipeline_layout),
137            vertex: wgpu::VertexState {
138                module,
139                entry_point: Some("vs_main"),
140                compilation_options: Default::default(),
141                buffers: &[],
142            },
143            primitive: wgpu::PrimitiveState {
144                topology: wgpu::PrimitiveTopology::TriangleList,
145                strip_index_format: None,
146                front_face: wgpu::FrontFace::Ccw,
147                cull_mode: None,
148                unclipped_depth: false,
149                polygon_mode: wgpu::PolygonMode::Fill,
150                conservative: false,
151            },
152            depth_stencil: None,
153            multisample: wgpu::MultisampleState::default(),
154            fragment: Some(wgpu::FragmentState {
155                module,
156                entry_point: Some("fs_main"),
157                compilation_options: Default::default(),
158                targets: &[Some(wgpu::ColorTargetState {
159                    format,
160                    blend: None,
161                    write_mask: wgpu::ColorWrites::ALL,
162                })],
163            }),
164            multiview_mask: None,
165            cache: None,
166        })
167    }
168
169    fn new(backend: &Backend) -> Self {
170        let device = &backend.device;
171        let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
172            label: Some("mipmap-blit-shader"),
173            source: wgpu::ShaderSource::Wgsl(MIP_SHADER.into()),
174        });
175
176        let filtering_layout = Self::bind_group_layout(device, true);
177        let nonfiltering_layout = Self::bind_group_layout(device, false);
178
179        let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
180            label: Some("mipmap-linear-sampler"),
181            mag_filter: wgpu::FilterMode::Linear,
182            min_filter: wgpu::FilterMode::Linear,
183            ..Default::default()
184        });
185        let nearest_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
186            label: Some("mipmap-nearest-sampler"),
187            mag_filter: wgpu::FilterMode::Nearest,
188            min_filter: wgpu::FilterMode::Nearest,
189            ..Default::default()
190        });
191
192        let mut pipelines = HashMap::new();
193        for &format in SUPPORTED_FORMATS {
194            let filterable = format != wgpu::TextureFormat::Rgba32Float;
195            let layout = if filterable { &filtering_layout } else { &nonfiltering_layout };
196            let pipeline = Self::build_pipeline(device, &module, layout, format);
197            pipelines.insert(format, MipPipeline { pipeline, filterable });
198        }
199
200        Self { filtering_layout, nonfiltering_layout, linear_sampler, nearest_sampler, pipelines }
201    }
202
203    pub(crate) fn generate_mips(
204        &self,
205        backend: &Backend,
206        texture: &wgpu::Texture,
207        format: wgpu::TextureFormat,
208        mip_count: u32,
209        layer_count: u32,
210    ) {
211        if mip_count <= 1 {
212            return;
213        }
214
215        let Some(entry) = self.pipelines.get(&format) else {
216            tracing::error!("MipmapGenerator: no blit pipeline for format {format:?}, skipping mip generation");
217            return;
218        };
219        let layout = if entry.filterable { &self.filtering_layout } else { &self.nonfiltering_layout };
220        let sampler = if entry.filterable { &self.linear_sampler } else { &self.nearest_sampler };
221
222        let device = &backend.device;
223        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
224            label: Some("mipmap-blit-encoder"),
225        });
226
227        for layer in 0..layer_count {
228            for level in 1..mip_count {
229                let src_view = texture.create_view(&wgpu::TextureViewDescriptor {
230                    dimension: Some(wgpu::TextureViewDimension::D2),
231                    base_mip_level: level - 1,
232                    mip_level_count: Some(1),
233                    base_array_layer: layer,
234                    array_layer_count: Some(1),
235                    ..Default::default()
236                });
237                let dst_view = texture.create_view(&wgpu::TextureViewDescriptor {
238                    dimension: Some(wgpu::TextureViewDimension::D2),
239                    base_mip_level: level,
240                    mip_level_count: Some(1),
241                    base_array_layer: layer,
242                    array_layer_count: Some(1),
243                    ..Default::default()
244                });
245
246                let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
247                    label: Some("mipmap-blit-bind-group"),
248                    layout,
249                    entries: &[
250                        wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&src_view) },
251                        wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
252                    ],
253                });
254
255                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
256                    label: Some("mipmap-blit-pass"),
257                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
258                        view: &dst_view,
259                        depth_slice: None,
260                        resolve_target: None,
261                        ops: wgpu::Operations {
262                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
263                            store: wgpu::StoreOp::Store,
264                        },
265                    })],
266                    depth_stencil_attachment: None,
267                    timestamp_writes: None,
268                    occlusion_query_set: None,
269                    multiview_mask: None,
270                });
271                pass.set_pipeline(&entry.pipeline);
272                pass.set_bind_group(0, &bind_group, &[]);
273                pass.draw(0..3, 0..1);
274                drop(pass);
275            }
276        }
277
278        backend.queue.submit(std::iter::once(encoder.finish()));
279    }
280}
281
282pub(crate) fn init_mipmap_generator(
283    backend: Option<Read<Backend>>,
284    existing: Option<Read<MipmapGenerator>>,
285    mut commands: Commands,
286) {
287    if existing.is_some() {
288        return;
289    }
290    let Some(backend) = backend else {
291        return;
292    };
293    commands.insert_resource(MipmapGenerator::new(&backend));
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn mip_count_is_one_when_mips_are_not_requested() {
302        assert_eq!(mip_count(2048, MipLevels::None), 1);
303        assert_eq!(mip_count(1, MipLevels::None), 1);
304        assert_eq!(mip_count(0, MipLevels::None), 1);
305    }
306
307    #[test]
308    fn mip_count_matches_a_full_power_of_two_chain() {
309        assert_eq!(mip_count(1, MipLevels::Full), 1);
310        assert_eq!(mip_count(2, MipLevels::Full), 2);
311        assert_eq!(mip_count(4, MipLevels::Full), 3);
312        assert_eq!(mip_count(256, MipLevels::Full), 9);
313        assert_eq!(mip_count(1024, MipLevels::Full), 11);
314    }
315
316    #[test]
317    fn mip_count_floors_a_non_power_of_two_dimension() {
318        assert_eq!(mip_count(300, MipLevels::Full), 9);
319    }
320
321    #[test]
322    fn mip_count_of_a_zero_dimension_does_not_panic_or_underflow() {
323        assert_eq!(mip_count(0, MipLevels::Full), 1);
324    }
325
326    #[test]
327    fn mip_count_fixed_is_used_as_is_when_within_the_full_chain_length() {
328        // 256 -> full chain is 9 levels; requesting 5 explicitly should be honored exactly.
329        assert_eq!(mip_count(256, MipLevels::Fixed(5)), 5);
330    }
331
332    #[test]
333    fn mip_count_fixed_is_clamped_to_the_full_chain_length() {
334        // Can't have more levels than the dimension actually supports.
335        assert_eq!(mip_count(256, MipLevels::Fixed(100)), 9);
336    }
337
338    #[test]
339    fn mip_count_fixed_is_clamped_to_at_least_one() {
340        assert_eq!(mip_count(256, MipLevels::Fixed(0)), 1);
341    }
342
343    #[test]
344    fn texture_usage_adds_render_attachment_only_when_there_is_more_than_one_mip() {
345        let single = texture_usage(1);
346        assert!(!single.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
347        assert!(single.contains(wgpu::TextureUsages::TEXTURE_BINDING));
348        assert!(single.contains(wgpu::TextureUsages::COPY_DST));
349
350        let chained = texture_usage(5);
351        assert!(chained.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
352        assert!(chained.contains(wgpu::TextureUsages::TEXTURE_BINDING));
353        assert!(chained.contains(wgpu::TextureUsages::COPY_DST));
354    }
355
356    #[test]
357    fn texture_usage_for_adds_render_attachment_when_there_is_no_source_data_even_at_a_single_mip() {
358        let empty = texture_usage_for(1, false);
359        assert!(empty.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
360        assert!(empty.contains(wgpu::TextureUsages::TEXTURE_BINDING));
361        assert!(empty.contains(wgpu::TextureUsages::COPY_DST));
362    }
363
364    #[test]
365    fn texture_usage_for_does_not_add_render_attachment_when_there_is_source_data_and_only_one_mip() {
366        let with_data = texture_usage_for(1, true);
367        assert!(!with_data.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
368    }
369
370    #[test]
371    fn texture_usage_for_still_adds_render_attachment_when_there_is_data_but_multiple_mips() {
372        let with_data_and_mips = texture_usage_for(5, true);
373        assert!(with_data_and_mips.contains(wgpu::TextureUsages::RENDER_ATTACHMENT));
374    }
375}