Skip to main content

fidget_raster/
pixel.rs

1//! 2D bitmap rendering / rasterization
2use super::RenderHandle;
3use crate::{
4    Image as GenericImage, RenderSize as _, RenderWorker, Tile, TileSizesRef,
5};
6use fidget_core::{
7    eval::Function,
8    render::{CancelToken, RenderHints, ThreadPool, TileSizes},
9    shape::{
10        BoundShape, ShapeBulkEval, ShapeBulkEvalError, ShapeTracingEval,
11        ShapeTracingEvalError, ShapeVars,
12    },
13    types::Interval,
14};
15use nalgebra::{Matrix3, Point2, Vector2};
16
17////////////////////////////////////////////////////////////////////////////////
18
19/// Image type for 2D rendering
20pub type Image = GenericImage<RawDistancePixel>;
21
22/// Size for 2D rendering
23pub type RenderSize = fidget_core::render::ImageSize;
24
25/// Settings for 2D rendering
26#[derive(Copy, Clone, Debug)]
27pub struct RenderConfig {
28    /// Render size
29    pub image_size: RenderSize,
30
31    /// World-to-model transform
32    pub world_to_model: Matrix3<f32>,
33
34    /// Render the distance values of individual pixels
35    pub pixel_perfect: bool,
36
37    /// Z position at which to render the slice
38    pub z: f32,
39}
40
41/// Evaluation settings for 2D rendering
42pub struct EvalConfig<'a> {
43    /// Tile sizes to use during evaluation.
44    ///
45    /// If this is `None`, then evaluation will use
46    /// [`RenderHints::tile_sizes_2d`] to select based on evaluator type.
47    pub tile_sizes: Option<TileSizes>,
48
49    /// Thread pool to use for rendering
50    ///
51    /// If this is `None`, then rendering is done in a single thread; otherwise,
52    /// the provided pool is used.
53    pub threads: Option<&'a ThreadPool>,
54
55    /// Token to cancel rendering
56    pub cancel: CancelToken,
57}
58
59impl crate::EvalConfig for EvalConfig<'_> {
60    fn threads(&self) -> Option<&ThreadPool> {
61        self.threads
62    }
63    fn is_cancelled(&self) -> bool {
64        self.cancel.is_cancelled()
65    }
66}
67
68impl Default for EvalConfig<'_> {
69    fn default() -> Self {
70        Self {
71            tile_sizes: None,
72            threads: Some(&ThreadPool::Global),
73            cancel: CancelToken::new(),
74        }
75    }
76}
77
78impl crate::RenderSize for RenderConfig {
79    fn width(&self) -> u32 {
80        self.image_size.width()
81    }
82    fn height(&self) -> u32 {
83        self.image_size.height()
84    }
85}
86
87impl RenderConfig {
88    /// Constructs a [`RenderConfig`] with reasonable defaults
89    ///
90    /// The default config has an identity transform matrix, is not
91    /// pixel-perfect, and renders at Z = 0.
92    ///
93    /// To build a somewhat-customized `RenderConfig`, it's typical to use this
94    /// as the base object, then replace individual fields:
95    ///
96    /// ```
97    /// # use fidget_raster::pixel::RenderConfig;
98    /// let cfg = RenderConfig {
99    ///     pixel_perfect: true, // override field
100    ///     ..RenderConfig::from_size(1024.into()) // base
101    /// };
102    /// ```
103    pub fn from_size(image_size: RenderSize) -> Self {
104        Self {
105            image_size,
106            world_to_model: Matrix3::identity(),
107            pixel_perfect: false,
108            z: 0.0,
109        }
110    }
111
112    /// Render a shape in 2D using this configuration and default [`EvalConfig`]
113    pub fn run<F: Function + RenderHints>(
114        &self,
115        shape: BoundShape<F, f32>,
116    ) -> Image {
117        render(shape, self, &Default::default())
118            .expect("no cancellation is possible")
119    }
120
121    /// Returns the combined screen-to-model transform matrix
122    pub fn mat(&self) -> Matrix3<f32> {
123        self.world_to_model * self.image_size.screen_to_world()
124    }
125}
126
127////////////////////////////////////////////////////////////////////////////////
128
129struct Scratch {
130    x: Vec<f32>,
131    y: Vec<f32>,
132    z: Vec<f32>,
133}
134
135impl Scratch {
136    fn new(size: usize) -> Self {
137        Self {
138            x: vec![0.0; size],
139            y: vec![0.0; size],
140            z: vec![0.0; size],
141        }
142    }
143}
144
145/// A raw pixel in a 2D image
146///
147/// This is either a single distance value or a description of a fill; both
148/// cases are packed into an `f32` (using `NaN`-boxing in the latter case).
149///
150/// The `NaN`-boxing is transparent to the user; for the curious, it is
151/// implemented by splitting the mantissa into three components:
152///
153/// - Bit 0 indicates the sign of the fill (1 for inside, 0 for outside)
154/// - Bits 1-9 indicate the depth at which evaluation terminated, which is
155///   useful for certain debug visualizations
156/// - Bits 10-17 are a fixed bit pattern, which both ensures that the value is
157///   treated as `NaN` in the `[empty, depth 0]` case (rather than infinity) and
158///   distinguishes from `NaN` values generated during normal evaluation
159#[derive(
160    Copy,
161    Clone,
162    Debug,
163    Default,
164    zerocopy::FromBytes,
165    zerocopy::Immutable,
166    zerocopy::IntoBytes,
167)]
168#[repr(C)]
169pub struct RawDistancePixel(f32);
170
171/// Unpacked data from a [`RawDistancePixel`]
172#[derive(Copy, Clone, Debug)]
173pub enum DistancePixel {
174    /// The pixel represents a pseudo-distance value
175    Value(f32),
176    /// The pixel is from a fill region and no distance is available
177    Fill {
178        /// Recursion depth which wrote the pixel
179        depth: u8,
180        /// Is the pixel inside or outside the image
181        inside: bool,
182    },
183}
184
185impl RawDistancePixel {
186    const KEY: u32 = 0b1111_0110 << 9;
187    const KEY_MASK: u32 = 0b1111_1111 << 9;
188
189    /// Checks whether the pixel is inside or outside the model
190    ///
191    /// Distances of exactly `0.0` and non-boxed `NaN` values are treated as
192    /// outside (i.e. returning `false`).
193    #[inline]
194    pub fn inside(self) -> bool {
195        match self.unpack() {
196            DistancePixel::Fill { inside, .. } => inside,
197            DistancePixel::Value(v) => v < 0.0,
198        }
199    }
200
201    /// Checks whether this is a distance point sample
202    #[inline]
203    pub fn is_distance(self) -> bool {
204        if !self.0.is_nan() {
205            return true;
206        }
207        let bits = self.0.to_bits();
208        (bits & Self::KEY_MASK) != Self::KEY
209    }
210
211    /// Unpacks into a [`DistancePixel`]
212    #[inline]
213    pub fn unpack(self) -> DistancePixel {
214        if self.is_distance() {
215            DistancePixel::Value(self.0)
216        } else {
217            let bits = self.0.to_bits();
218            let inside = (bits & 1) == 1;
219            let depth = (bits >> 1) as u8;
220            DistancePixel::Fill { inside, depth }
221        }
222    }
223
224    /// Returns the distance value if this is a [`DistancePixel::Value`]
225    #[inline]
226    pub fn distance(self) -> Option<f32> {
227        if let DistancePixel::Value(v) = self.unpack() {
228            Some(v)
229        } else {
230            None
231        }
232    }
233}
234
235impl From<DistancePixel> for RawDistancePixel {
236    #[inline]
237    fn from(p: DistancePixel) -> Self {
238        match p {
239            DistancePixel::Value(f) => Self::from(f),
240            DistancePixel::Fill { depth, inside } => {
241                let bits = 0x7FC00000
242                    | (u32::from(depth) << 1)
243                    | u32::from(inside)
244                    | Self::KEY;
245                Self(f32::from_bits(bits))
246            }
247        }
248    }
249}
250
251impl From<f32> for RawDistancePixel {
252    #[inline]
253    fn from(p: f32) -> Self {
254        // Canonicalize the NAN value to avoid colliding with a fill
255        Self(if p.is_nan() { f32::NAN } else { p })
256    }
257}
258
259////////////////////////////////////////////////////////////////////////////////
260
261/// Per-thread worker
262struct Worker<'a, F: Function> {
263    tile_sizes: TileSizesRef<'a>,
264    vars: &'a ShapeVars<f32>,
265    z: f32,
266
267    pixel_perfect: bool,
268    scratch: Scratch,
269    transform: nalgebra::Matrix4<f32>,
270
271    eval_float_slice: ShapeBulkEval<F::FloatSliceEval>,
272    eval_interval: ShapeTracingEval<F::IntervalEval>,
273
274    /// Spare tape storage for reuse
275    tape_storage: Vec<F::TapeStorage>,
276
277    /// Spare shape storage for reuse
278    shape_storage: Vec<F::Storage>,
279
280    /// Workspace for shape simplification
281    workspace: F::Workspace,
282
283    /// Tile being rendered
284    ///
285    /// This is a root tile, i.e. width and height of `config.tile_sizes[0]`
286    image: Image,
287}
288
289impl<'a, F: Function> RenderWorker<'a, F> for Worker<'a, F> {
290    type Config = RenderConfig;
291    type Output = Image;
292    fn new(
293        cfg: &'a Self::Config,
294        tile_sizes: TileSizesRef<'a>,
295        vars: &'a ShapeVars<f32>,
296    ) -> Self {
297        // Convert to a 4x4 matrix and apply to the shape
298        let transform = cfg.mat();
299        let transform = transform.insert_row(2, 0.0);
300        let mut transform = transform.insert_column(2, 0.0);
301        transform[(2, 2)] = 1.0; // preserve Z
302
303        Worker::<F> {
304            tile_sizes,
305            transform,
306            vars,
307
308            scratch: Scratch::new(tile_sizes.last().pow(2)),
309            pixel_perfect: cfg.pixel_perfect,
310            z: cfg.z,
311            image: Default::default(),
312            eval_float_slice: Default::default(),
313            eval_interval: Default::default(),
314            tape_storage: vec![],
315            shape_storage: vec![],
316            workspace: Default::default(),
317        }
318    }
319
320    fn render_tile(
321        &mut self,
322        shape: &mut RenderHandle<F>,
323        tile: Tile<2>,
324    ) -> Self::Output {
325        self.image = Image::new((self.tile_sizes[0] as u32).into());
326        self.render_tile_recurse(shape, 0, tile);
327        std::mem::take(&mut self.image)
328    }
329}
330
331impl<F: Function> Worker<'_, F> {
332    fn render_tile_recurse(
333        &mut self,
334        shape: &mut RenderHandle<F>,
335        depth: usize,
336        tile: Tile<2>,
337    ) {
338        let tile_size = self.tile_sizes[depth];
339
340        // Find the interval bounds of the region, in screen coordinates
341        let base = Point2::from(tile.corner).cast::<f32>();
342        let x = Interval::new(base.x, base.x + tile_size as f32);
343        let y = Interval::new(base.y, base.y + tile_size as f32);
344        let z = Interval::new(self.z, self.z);
345
346        // Evaluation applies the world-to-model transform.  We know that vars
347        // are valid because we check them at the top of `render`
348        let (i, simplify) =
349            match self.eval_interval.eval_with_transform_and_vars(
350                shape.i_tape(&mut self.tape_storage),
351                x,
352                y,
353                z,
354                &self.transform,
355                self.vars,
356            ) {
357                Ok(v) => v,
358                Err(ShapeTracingEvalError::MissingVar(..)) => unreachable!(),
359            };
360
361        if !self.pixel_perfect {
362            let pixel = if i.upper() < 0.0 {
363                Some(DistancePixel::Fill {
364                    inside: true,
365                    depth: depth as u8,
366                })
367            } else if i.lower() > 0.0 {
368                Some(DistancePixel::Fill {
369                    inside: false,
370                    depth: depth as u8,
371                })
372            } else {
373                None
374            };
375            if let Some(pixel) = pixel {
376                let fill = pixel.into();
377                for y in 0..tile_size {
378                    let start = self
379                        .tile_sizes
380                        .pixel_offset(tile.add(Vector2::new(0, y)));
381                    self.image[start..][..tile_size].fill(fill);
382                }
383                return;
384            }
385        }
386
387        let sub_tape = if let Some(trace) = simplify.as_ref() {
388            shape.simplify(
389                trace,
390                &mut self.workspace,
391                &mut self.shape_storage,
392                &mut self.tape_storage,
393            )
394        } else {
395            shape
396        };
397
398        if let Some(next_tile_size) = self.tile_sizes.get(depth + 1) {
399            let n = tile_size / next_tile_size;
400            for j in 0..n {
401                for i in 0..n {
402                    self.render_tile_recurse(
403                        sub_tape,
404                        depth + 1,
405                        Tile::new(
406                            tile.corner + Vector2::new(i, j) * next_tile_size,
407                        ),
408                    );
409                }
410            }
411        } else {
412            self.render_tile_pixels(sub_tape, tile_size, tile);
413        }
414    }
415
416    fn render_tile_pixels(
417        &mut self,
418        shape: &mut RenderHandle<F>,
419        tile_size: usize,
420        tile: Tile<2>,
421    ) {
422        let mut index = 0;
423        for j in 0..tile_size {
424            for i in 0..tile_size {
425                self.scratch.x[index] = (tile.corner[0] + i) as f32;
426                self.scratch.y[index] = (tile.corner[1] + j) as f32;
427                self.scratch.z[index] = self.z;
428                index += 1;
429            }
430        }
431
432        let out = match self.eval_float_slice.eval_with_transform_and_vars(
433            shape.f_tape(&mut self.tape_storage),
434            &self.scratch.x,
435            &self.scratch.y,
436            &self.scratch.z,
437            &self.transform,
438            self.vars,
439        ) {
440            Ok(v) => v,
441            // We checked the var map at the beginning of `render`
442            Err(ShapeBulkEvalError::MissingVar(..))
443            // We know that our X/Y/Z slices are all the same length
444            | Err(ShapeBulkEvalError::MismatchedVarSlices { .. })
445                => unreachable!(),
446        };
447
448        let mut index = 0;
449        for j in 0..tile_size {
450            let o = self.tile_sizes.pixel_offset(tile.add(Vector2::new(0, j)));
451            for i in 0..tile_size {
452                self.image[o + i] = out[index].into();
453                index += 1;
454            }
455        }
456    }
457}
458
459////////////////////////////////////////////////////////////////////////////////
460
461/// Renders a shape into a 2D image, with the provided configuration
462///
463/// The shape provides the evaluator backend (`F`) and bound variables; the
464/// configuration supplies resolution, transforms, etc.
465///
466/// Returns [`Some(Image)`](Image) of pixel data if rendering succeeds, or
467/// `None` if rendering was cancelled (using the [`EvalConfig::cancel`].
468pub fn render<F: Function + RenderHints>(
469    b: BoundShape<F, f32>,
470    render_config: &RenderConfig,
471    eval_config: &EvalConfig,
472) -> Option<Image> {
473    let shape = b.shape();
474    let vars = b.vars();
475    let max_size = render_config.width().max(render_config.height()) as usize;
476    let default_tile_sizes;
477    let tile_sizes = if let Some(ts) = &eval_config.tile_sizes {
478        TileSizesRef::new(ts, max_size)
479    } else {
480        default_tile_sizes = F::tile_sizes_2d();
481        TileSizesRef::new(&default_tile_sizes, max_size)
482    };
483    let tiles = super::render_tiles::<F, Worker<F>, _>(
484        shape.clone(),
485        vars,
486        render_config,
487        eval_config,
488        tile_sizes,
489    )?;
490
491    let width = render_config.image_size.width() as usize;
492    let height = render_config.image_size.height() as usize;
493    let mut image = Image::new(render_config.image_size);
494    for (tile, data) in tiles.iter() {
495        let mut index = 0;
496        for j in 0..tile_sizes[0] {
497            let y = j + tile.corner.y;
498            for i in 0..tile_sizes[0] {
499                let x = i + tile.corner.x;
500                if y < height && x < width {
501                    image[(y, x)] = data[index];
502                }
503                index += 1;
504            }
505        }
506    }
507    Some(image)
508}
509
510#[cfg(test)]
511mod test {
512    use super::*;
513    use fidget_core::{
514        Context,
515        shape::Shape,
516        var::Var,
517        vm::{VmFunction, VmShape},
518    };
519
520    const HI: &str =
521        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../models/hi.vm"));
522
523    #[test]
524    fn render2d_cancel() {
525        let (ctx, root) = Context::from_text(HI.as_bytes()).unwrap();
526        let shape = Shape::<VmFunction>::new(&ctx, root)
527            .unwrap()
528            .try_into()
529            .expect("no vars");
530
531        let render_cfg = RenderConfig::from_size(128.into());
532        let eval_cfg = EvalConfig::default();
533        let cancel = eval_cfg.cancel.clone();
534        cancel.cancel();
535        assert!(render(shape, &render_cfg, &eval_cfg).is_none());
536    }
537
538    #[test]
539    fn shape_with_var() {
540        let mut ctx = Context::new();
541        let x = ctx.x();
542        let var = Var::new();
543        let v = ctx.var(var);
544        let s = ctx.sub(x, v).unwrap();
545        let shape = VmShape::new(&ctx, s).unwrap();
546
547        let cfg = RenderConfig::from_size(64.into());
548        let mut vars = ShapeVars::new();
549        let i = var.index().expect("expected Var::V");
550        vars.insert(i, 1.0);
551        cfg.run(shape.bind(&vars).expect("all vars present"));
552    }
553
554    #[test]
555    fn render_config_z() {
556        let mut ctx = Context::new();
557        let z = ctx.z();
558        let shape = VmShape::new(&ctx, z).unwrap();
559
560        let out = RenderConfig {
561            z: -1.0,
562            ..RenderConfig::from_size(64.into())
563        }
564        .run(shape.clone().try_into().expect("no vars"));
565        for p in out.iter() {
566            assert!(p.inside());
567        }
568
569        let out = RenderConfig {
570            z: 0.0,
571            ..RenderConfig::from_size(64.into())
572        }
573        .run(shape.clone().try_into().expect("no vars"));
574        for p in out.iter() {
575            assert!(!p.inside());
576        }
577
578        let out = RenderConfig {
579            z: 1.0,
580            ..RenderConfig::from_size(64.into())
581        }
582        .run(shape.try_into().expect("no vars"));
583        for p in out.iter() {
584            assert!(!p.inside());
585        }
586    }
587
588    #[test]
589    fn test_render_config_transforms() {
590        let config = RenderConfig::from_size(512.into());
591        let mat = config.mat();
592        assert_eq!(
593            mat.transform_point(&Point2::new(0.0, -1.0)),
594            Point2::new(-1.0, 1.0)
595        );
596        assert_eq!(
597            mat.transform_point(&Point2::new(512.0, -1.0)),
598            Point2::new(1.0, 1.0)
599        );
600        assert_eq!(
601            mat.transform_point(&Point2::new(512.0, 511.0)),
602            Point2::new(1.0, -1.0)
603        );
604
605        let config = RenderConfig::from_size(575.into());
606        let mat = config.mat();
607        assert_eq!(
608            mat.transform_point(&Point2::new(0.0, -1.0)),
609            Point2::new(-1.0, 1.0)
610        );
611        assert_eq!(
612            mat.transform_point(&Point2::new(
613                config.image_size.width() as f32,
614                -1.0
615            )),
616            Point2::new(1.0, 1.0)
617        );
618        assert_eq!(
619            mat.transform_point(&Point2::new(
620                config.image_size.width() as f32,
621                config.image_size.height() as f32 - 1.0,
622            )),
623            Point2::new(1.0, -1.0)
624        );
625    }
626}