Skip to main content

fidget_wgpu/pixel/effects/
mod.rs

1//! Pixel post-processing pipelines
2//!
3//! There is a notable difference between voxel and pixel post-processing:
4//! instead of producing a fully-populated RGBA image to be drawn to the screen,
5//! we produce two images: one containing [`RawDistancePixel`] values, and one
6//! containing RGBA values (as 4-byte words).
7//!
8//! This is because – when the final shader draws to the screen – we can improve
9//! visual fidelity by interpolating between distance values (typically by the
10//! texture unit).  If we baked the final RGBA image, then drawing it at a
11//! different scale (e.g. the user has zoomed and we're waiting for the next
12//! image to be completed) would simply be blurry; with distance interpolation,
13//! it remains sharper (though not pixel-perfect).
14//!
15//! Output is stored in the [`MergeWorkspace`] object, and may be accessed with
16//! [`output_distance`](MergeWorkspace::output_distance) and
17//! [`output_color`](MergeWorkspace::output_color).
18//! Note that if color has not been computed, `output_color` will return `None`.
19use crate::{
20    CopyVarsError, Gpu, RegPipeline,
21    buf::{BufferSizeError, FlexBuffer, buffer_ro, buffer_rw, buffer_uniform},
22    color::{ColorWorkspace as GenericColorWorkspace, ShapeColorBuffers},
23    pixel::{PixelBufferTag, RawDistancePixel},
24    shaders, tag,
25};
26use fidget_core::{render::ImageSize, shape::ShapeVars};
27use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
28
29pub use crate::voxel::effects::ColorError;
30
31const MERGE_SHADER: &str = include_str!("shaders/merge.wgsl");
32const COLOR_SHADER: &str = include_str!("shaders/color.wgsl");
33
34/// Workspace for evaluating color expressions
35pub type ColorWorkspace = GenericColorWorkspace<ColorConfig>;
36
37/// Returns a shader for merging images
38fn merge_shader() -> String {
39    MERGE_SHADER.to_owned() + shaders::COMMON + super::DISTANCE_PIXEL_SHADER
40}
41
42fn color_shader(reg_count: u8) -> String {
43    let mut shader_code = shaders::opcode_constants();
44    shader_code += &format!("const REG_COUNT: u32 = {reg_count};");
45    shader_code
46        + COLOR_SHADER
47        + super::TRANSFORM_INPUT
48        + super::DISTANCE_PIXEL_SHADER
49        + shaders::COMMON
50        + shaders::TAPE_INTERPRETER
51        + shaders::DUMMY_STACK
52        + shaders::FLOAT_OPS
53}
54
55////////////////////////////////////////////////////////////////////////////////
56
57/// Type indicating an image size mismatch
58#[derive(Debug, thiserror::Error)]
59#[error(
60    "image size mismatch: expected {} × {}, got {} × {}",
61    expected.width(), expected.height(),
62    actual.width(), actual.height(),
63)]
64pub struct ImageSizeMismatch {
65    expected: ImageSize,
66    actual: ImageSize,
67}
68
69/// Error returned when submitting a merge operation
70#[derive(Debug, thiserror::Error)]
71pub enum MergeError {
72    /// Image sizes in the slice are not consistent
73    #[error(transparent)]
74    ImageSizeMismatch(#[from] ImageSizeMismatch),
75
76    /// An error occurred while resizing the output buffer
77    #[error(transparent)]
78    OutputSize(BufferSizeError),
79}
80
81////////////////////////////////////////////////////////////////////////////////
82
83tag!(
84    pub PixelDistanceBufferTag,
85    RawDistancePixel,
86    ImageSize,
87    STORAGE | COPY_SRC,
88    "Buffer tag for on-GPU distance images"
89);
90
91tag!(
92    pub PixelColorBufferTag,
93    u32, // also doubles as shape index, but not in user-visible APIs
94    ImageSize,
95    STORAGE | COPY_SRC,
96    "Buffer tag for on-GPU color images"
97);
98
99/// Handle to a set of buffers used when merging images
100pub struct MergeWorkspace {
101    config: wgpu::Buffer,
102    distance: FlexBuffer<PixelDistanceBufferTag>,
103    color: FlexBuffer<PixelColorBufferTag>,
104
105    color_bind_group: std::cell::OnceCell<wgpu::BindGroup>,
106
107    /// Number of images merged together
108    image_count: usize,
109
110    /// Indicates whether the `color` buffer represents color
111    ///
112    /// When this is `false`, the `color` buffer represents shape index instead,
113    /// and [`output_color`](Self::output_color) returns `None`.
114    has_color: bool,
115}
116
117impl MergeWorkspace {
118    /// Resets the merge buffer
119    ///
120    /// The next call to [`Context::submit_merge`] will clear the buffer and
121    /// begin accumulating from scratch.
122    pub fn reset(&mut self) {
123        self.has_color = false;
124        self.image_count = 0;
125    }
126
127    /// Returns `true` if the output color buffer is valid
128    pub fn has_color(&self) -> bool {
129        self.has_color
130    }
131
132    /// Returns a handle to the distance output buffer
133    pub fn output_distance(&self) -> &FlexBuffer<PixelDistanceBufferTag> {
134        &self.distance
135    }
136
137    /// Returns a handle to the color output buffer, if populated
138    pub fn output_color(&self) -> Option<&FlexBuffer<PixelColorBufferTag>> {
139        if self.has_color {
140            Some(&self.color)
141        } else {
142            None
143        }
144    }
145}
146
147/// Mirror of the WGSL `MergeConfig` object
148#[derive(Copy, Clone, FromBytes, Immutable, IntoBytes, KnownLayout)]
149#[cfg_attr(test, derive(facet::Facet))]
150#[repr(C)]
151pub(crate) struct MergeConfig {
152    /// Image size, in pixels
153    pub image_size: [u32; 2],
154
155    /// Whether or not convert NaN values to distance values
156    pub remove_nans: u32,
157
158    /// Offset applied to indices when merging
159    ///
160    /// When this is 0, we initialize the output image
161    pub index_base: u32,
162}
163
164////////////////////////////////////////////////////////////////////////////////
165
166/// WGPU context for applying various effects
167pub struct Context {
168    gpu: Gpu,
169
170    merge_bind_group_layout: wgpu::BindGroupLayout,
171    merge_pipeline: wgpu::ComputePipeline,
172
173    color_ctx: ColorContext,
174}
175
176impl Context {
177    /// Builds a new context for applying effects
178    pub fn new(gpu: &Gpu) -> Self {
179        let merge_bind_group_layout = gpu.device.create_bind_group_layout(
180            &wgpu::BindGroupLayoutDescriptor {
181                label: None,
182                entries: &[
183                    buffer_uniform(0),
184                    buffer_ro(1), // image
185                    buffer_rw(2), // distance
186                    buffer_rw(3), // color (used here as an index)
187                ],
188            },
189        );
190        let shader_code = merge_shader();
191        let pipeline_layout = gpu.device.create_pipeline_layout(
192            &wgpu::PipelineLayoutDescriptor {
193                label: Some("effects merge pipeline"),
194                bind_group_layouts: &[Some(&merge_bind_group_layout)],
195                immediate_size: 0u32,
196            },
197        );
198        let shader_module =
199            gpu.device
200                .create_shader_module(wgpu::ShaderModuleDescriptor {
201                    label: Some("effects merge shader module"),
202                    source: wgpu::ShaderSource::Wgsl(shader_code.into()),
203                });
204        let merge_pipeline = gpu.device.create_compute_pipeline(
205            &wgpu::ComputePipelineDescriptor {
206                label: Some("effects merge compute pipeline"),
207                layout: Some(&pipeline_layout),
208                module: &shader_module,
209                entry_point: Some("merge_main"),
210                compilation_options: Default::default(),
211                cache: None,
212            },
213        );
214
215        let color_ctx = ColorContext::new(&gpu.device);
216
217        Self {
218            gpu: gpu.clone(),
219            merge_bind_group_layout,
220            merge_pipeline,
221            color_ctx,
222        }
223    }
224
225    /// Submits a set of merge operations to accumulate a single image
226    ///
227    /// [`MergeWorkspace::reset`] should be called before the first call to
228    /// `submit_merge`. For the first merge after a reset, the output buffer is
229    /// resized to fit the images; subsequent merges must be of the same size.
230    pub fn submit_merge(
231        &self,
232        image: &FlexBuffer<PixelBufferTag>,
233        remove_nans: bool,
234        buf: &mut MergeWorkspace,
235    ) -> Result<(), MergeError> {
236        let size = image.size();
237        if buf.image_count > 0 {
238            let buf_size = buf.distance.size();
239            if size != buf_size {
240                return Err(ImageSizeMismatch {
241                    expected: size,
242                    actual: buf_size,
243                }
244                .into());
245            }
246        } else {
247            // Resize the buffers, clearing the bind group if either has
248            // changed.  It would be incorrect for one to change and not the
249            // other, so we also have an assertion to make sure they agree!
250            let distance_changed = !matches!(
251                buf.distance
252                    .grow_to_fit(&self.gpu.device, size)
253                    .map_err(MergeError::OutputSize)?,
254                std::cmp::Ordering::Equal
255            );
256            let color_changed = !matches!(
257                buf.color
258                    .grow_to_fit(&self.gpu.device, size)
259                    .map_err(MergeError::OutputSize)?,
260                std::cmp::Ordering::Equal
261            );
262            assert_eq!(distance_changed, color_changed, "mismatched buffers");
263            if distance_changed {
264                buf.color_bind_group = Default::default();
265            }
266        }
267        buf.has_color = false;
268        let mut encoder = self.gpu.device.create_command_encoder(
269            &wgpu::CommandEncoderDescriptor {
270                label: Some("merge compute encoder"),
271            },
272        );
273        // Scope to bound the lifetime of compute_pass
274        {
275            let mut compute_pass =
276                encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
277                    label: Some("merge compute pass"),
278                    timestamp_writes: None, // TODO add timestamps?
279                });
280            compute_pass.set_pipeline(&self.merge_pipeline);
281            let cfg = MergeConfig {
282                image_size: [size.width(), size.height()],
283                remove_nans: remove_nans as u32,
284                index_base: buf.image_count as u32,
285            };
286            {
287                let mut writer = self
288                    .gpu
289                    .queue
290                    .write_buffer_with(
291                        &buf.config,
292                        0,
293                        (std::mem::size_of::<MergeConfig>() as u64)
294                            .try_into()
295                            .unwrap(),
296                    )
297                    .unwrap();
298                writer.copy_from_slice(cfg.as_bytes());
299            }
300
301            let bg =
302                self.gpu
303                    .device
304                    .create_bind_group(&wgpu::BindGroupDescriptor {
305                        label: Some("merge bind group"),
306                        layout: &self.merge_bind_group_layout,
307                        entries: &[
308                            wgpu::BindGroupEntry {
309                                binding: 0,
310                                resource: buf.config.as_entire_binding(),
311                            },
312                            wgpu::BindGroupEntry {
313                                binding: 1,
314                                resource: image.bind_active(),
315                            },
316                            wgpu::BindGroupEntry {
317                                binding: 2,
318                                resource: buf.distance.bind_active(),
319                            },
320                            wgpu::BindGroupEntry {
321                                binding: 3,
322                                resource: buf.color.bind_active(),
323                            },
324                        ],
325                    });
326            compute_pass.set_bind_group(0, Some(&bg), &[]);
327            compute_pass.dispatch_workgroups(
328                size.width().div_ceil(8),
329                size.height().div_ceil(8),
330                1,
331            );
332            buf.image_count += 1;
333        }
334        self.gpu.queue.submit(Some(encoder.finish()));
335        Ok(())
336    }
337
338    /// Builds a new set of [`MergeWorkspace`] for the given image size
339    pub fn merge_workspace(&self) -> MergeWorkspace {
340        let config = self.gpu.device.create_buffer(&wgpu::BufferDescriptor {
341            label: Some("config"),
342            size: std::mem::size_of::<MergeConfig>() as u64,
343            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
344            mapped_at_creation: false,
345        });
346        let distance = FlexBuffer::new(
347            &self.gpu.device,
348            "pixel merge distance",
349            64.into(),
350        )
351        .unwrap();
352        let color =
353            FlexBuffer::new(&self.gpu.device, "pixel merge color", 64.into())
354                .unwrap();
355        MergeWorkspace {
356            config,
357            distance,
358            color,
359            color_bind_group: Default::default(),
360            image_count: 0,
361            has_color: false,
362        }
363    }
364
365    /// Submits a color evaluation pass
366    ///
367    /// Image size is set from the `MergeWorkspace`; the transform matrix is
368    /// provided separately (but should be the same one used for image
369    /// evaluation).
370    pub fn submit_color(
371        &self,
372        merge: &mut MergeWorkspace,
373        settings: ColorSettings,
374        shape: &ShapeColorBuffers,
375        bufs: &mut ColorWorkspace,
376    ) -> Result<(), ColorError> {
377        self.submit_color_with_vars(
378            merge,
379            settings,
380            shape,
381            bufs,
382            &Default::default(),
383        )
384    }
385
386    /// Submits a color evaluation pass with auxiliary variables
387    ///
388    /// Image size is set from the `MergeWorkspace`; the transform matrix is
389    /// provided separately (but should be the same one used for image
390    /// evaluation).
391    pub fn submit_color_with_vars(
392        &self,
393        merge: &mut MergeWorkspace,
394        settings: ColorSettings,
395        shape: &ShapeColorBuffers,
396        bufs: &mut ColorWorkspace,
397        vars: &ShapeVars<f32>,
398    ) -> Result<(), ColorError> {
399        self.color_ctx
400            .submit(merge, settings, shape, bufs, vars, &self.gpu)
401    }
402
403    /// Returns a new workspace for color evaluation
404    pub fn color_workspace(&self) -> ColorWorkspace {
405        ColorWorkspace::new(&self.gpu.device)
406    }
407}
408
409////////////////////////////////////////////////////////////////////////////////
410
411/// Configuration for the color evaluation pass
412///
413/// This is quietly public because it's part of a public API, but is unlikely to
414/// be useful for end-users of the library.
415#[derive(Copy, Clone, FromBytes, Immutable, IntoBytes, KnownLayout)]
416#[cfg_attr(test, derive(facet::Facet))]
417#[repr(C)]
418#[doc(hidden)]
419pub struct ColorConfig {
420    /// Screen-to-model transform matrix (mat3x3)
421    mat: [f32; 12],
422
423    /// Input index of X, Y, Z axes
424    ///
425    /// `u32::MAX` is used as a marker if an axis is unused
426    axes: [u32; 3],
427
428    /// Z height at which to evaluate the colors
429    z: f32,
430
431    /// Image size (pixels)
432    image_size: [u32; 2],
433
434    ///When non-zero, only compute color for filled pixels
435    only_filled: u32,
436
437    // alignment
438    _pad: [u32; 3],
439    // this is followed by a tape_data flexible array member
440}
441
442/// Settings for per-pixel color evaluation
443#[derive(Copy, Clone, Debug)]
444pub struct ColorSettings {
445    /// Z height at which to evaluate the colors
446    pub z: f32,
447
448    /// When non-zero, only compute color for filled pixels
449    pub only_filled: bool,
450
451    /// Transform matrix
452    ///
453    /// This should be the same transform matrix used to evaluate the original
454    /// pixel image (unless you're doing something weird)
455    pub world_to_model: nalgebra::Matrix3<f32>,
456}
457
458struct ColorContext {
459    /// Configuration bind group layout (shape-specific, includes tape data)
460    config_bind_group_layout: wgpu::BindGroupLayout,
461
462    /// Image bind groups (input and output)
463    image_bind_group_layout: wgpu::BindGroupLayout,
464
465    /// Pipeline for computing per-pixel color
466    color_pipeline: RegPipeline,
467}
468
469impl ColorContext {
470    pub fn new(device: &wgpu::Device) -> Self {
471        let config_bind_group_layout =
472            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
473                label: Some("color config and shape"),
474                entries: &[buffer_ro(0), buffer_ro(1), buffer_ro(2)],
475            });
476        let image_bind_group_layout =
477            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
478                label: Some("color images"),
479                entries: &[
480                    buffer_ro(0), // distance
481                    buffer_rw(1), // color
482                ],
483            });
484
485        let pipeline_layout =
486            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
487                label: Some("color pipeline"),
488                bind_group_layouts: &[
489                    Some(&config_bind_group_layout),
490                    Some(&image_bind_group_layout),
491                ],
492                immediate_size: 0u32,
493            });
494        let device_ = device.clone();
495        let color_pipeline = RegPipeline::build(Box::new(move |reg_count| {
496            let shader_code = color_shader(reg_count);
497            let shader_module =
498                device_.create_shader_module(wgpu::ShaderModuleDescriptor {
499                    label: None,
500                    source: wgpu::ShaderSource::Wgsl(shader_code.into()),
501                });
502            device_.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
503                label: Some(&format!("color ({reg_count})")),
504                layout: Some(&pipeline_layout),
505                module: &shader_module,
506                entry_point: Some("color_main"),
507                compilation_options: Default::default(),
508                cache: None,
509            })
510        }));
511
512        Self {
513            config_bind_group_layout,
514            image_bind_group_layout,
515            color_pipeline,
516        }
517    }
518
519    fn submit(
520        &self,
521        image: &mut MergeWorkspace,
522        settings: ColorSettings,
523        shape: &ShapeColorBuffers,
524        bufs: &mut ColorWorkspace,
525        vars: &ShapeVars<f32>,
526        gpu: &Gpu,
527    ) -> Result<(), ColorError> {
528        if image.image_count != shape.shape_count() {
529            return Err(ColorError::BadShapeCount {
530                merge_count: image.image_count,
531                shape_count: shape.shape_count(),
532            });
533        } else if image.has_color {
534            return Err(ColorError::AlreadyHasColor);
535        }
536        let size = image.distance.size();
537        let mat = settings.world_to_model
538            * ImageSize::new(size.width(), size.height()).screen_to_world();
539        let mut mat4 = nalgebra::Matrix4x3::<f32>::identity();
540        mat4.fixed_view_mut::<3, 3>(0, 0).copy_from(&mat);
541
542        bufs.copy_vars(gpu, shape.var_map(), vars)
543            .map_err(|e| match e {
544                CopyVarsError::BufferSize(b) => ColorError::VarBufferSize(b),
545                CopyVarsError::MissingVar(v) => ColorError::MissingVar(v),
546            })?;
547        let config = ColorConfig {
548            mat: mat4.data.as_slice().try_into().unwrap(),
549            axes: shape.axes(),
550            image_size: [size.width(), size.height()],
551            only_filled: settings.only_filled.into(),
552            _pad: [0; 3],
553            z: settings.z,
554        };
555        bufs.copy_config_and_tape(gpu, &config, shape.bytecode())
556            .map_err(ColorError::ConfigBufferSize)?;
557        bufs.copy_shape_starts(gpu, shape.shape_start())
558            .expect("shape starts should always fit if shape bytecode fits");
559
560        let config_bg =
561            bufs.config_bind_group(&gpu.device, &self.config_bind_group_layout);
562
563        // Create a command encoder and dispatch the compute work
564        let mut encoder = gpu.device.create_command_encoder(
565            &wgpu::CommandEncoderDescriptor { label: None },
566        );
567        {
568            let mut compute_pass =
569                encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
570                    label: None,
571                    timestamp_writes: None, // TODO add timestamps?
572                });
573            compute_pass.set_bind_group(0, config_bg, &[]);
574            let image_bg = image.color_bind_group.get_or_init(|| {
575                gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
576                    label: Some("color image bind group"),
577                    layout: &self.image_bind_group_layout,
578                    entries: &[
579                        wgpu::BindGroupEntry {
580                            binding: 0,
581                            resource: image.distance.bind_active(),
582                        },
583                        wgpu::BindGroupEntry {
584                            binding: 1,
585                            resource: image.color.bind_active(),
586                        },
587                    ],
588                })
589            });
590            compute_pass.set_bind_group(1, image_bg, &[]);
591            compute_pass
592                .set_pipeline(self.color_pipeline.get(shape.reg_count()));
593            compute_pass.dispatch_workgroups(
594                size.width().div_ceil(8),
595                size.height().div_ceil(8),
596                1,
597            );
598        }
599        gpu.queue.submit(Some(encoder.finish()));
600        image.has_color = true;
601        Ok(())
602    }
603}
604
605////////////////////////////////////////////////////////////////////////////////
606
607#[cfg(test)]
608mod test {
609    use super::*;
610
611    #[test]
612    fn compile_merge_shader() {
613        crate::compile_shader(&merge_shader(), "merge");
614    }
615
616    #[test]
617    fn compile_color_shader() {
618        crate::compile_shader(&color_shader(16), "color");
619    }
620
621    #[test]
622    fn merge_config_layout() {
623        crate::test::compare_struct_layout::<MergeConfig>(
624            &merge_shader(),
625            "MergeConfig",
626        );
627    }
628
629    #[test]
630    fn color_config_layout() {
631        crate::test::compare_struct_layout::<ColorConfig>(
632            &color_shader(16),
633            "Config",
634        );
635    }
636}