Skip to main content

motion_content/vector/ink/brush/advanced/
material.rs

1//! Brush material baking: paper textures, paint sampling and tiled distance-field material.
2
3use super::super::{BrushStroke, Prepared, invalid};
4use super::geometry::{outline, rotated, samples};
5use crate::ImageAsset;
6use motion_core::{GradientExtend, ImagePattern, Paint, Point, Rect, Transform, ValidationError};
7use std::sync::Arc;
8
9/// Segments per culling block in the material distance search.
10const MATERIAL_BLOCK: usize = 16;
11
12/// Repeating immutable paper height map sampled in local design coordinates.
13#[derive(Clone, Debug)]
14pub struct Paper {
15    /// Already resolved image; no implicit file loading during rendering.
16    pub texture: Arc<ImageAsset>,
17    /// Local design units per texel.
18    pub grain_scale: f64,
19    /// Coverage attenuation by the paper height, in 0..=1.
20    pub absorbency: f64,
21}
22
23fn image_sample(pattern: &ImagePattern, point: Point) -> Result<[f64; 4], ValidationError> {
24    let inverse = pattern
25        .transform
26        .inverse()
27        .ok_or_else(|| invalid("paint", "Noninvertible paint transform"))?;
28    let point = inverse.apply(point);
29    let width = pattern.image.width() as usize;
30    let height = pattern.image.height() as usize;
31    if pattern.decal
32        && (point.x < 0.0 || point.y < 0.0 || point.x >= width as f64 || point.y >= height as f64)
33    {
34        return Ok([0.0; 4]);
35    }
36    let horizontal = point.x - 0.5;
37    let vertical = point.y - 0.5;
38    let column = horizontal.floor();
39    let row = vertical.floor();
40    let fraction = [horizontal - column, vertical - row];
41    let address = |index: i64, length: usize| match pattern.extend {
42        GradientExtend::Pad => index.clamp(0, length as i64 - 1) as usize,
43        GradientExtend::Repeat => index.rem_euclid(length as i64) as usize,
44        GradientExtend::Reflect => {
45            let index = index.rem_euclid(length as i64 * 2) as usize;
46            if index >= length {
47                length * 2 - index - 1
48            } else {
49                index
50            }
51        }
52    };
53    let mut result = [0.0; 4];
54    for vertical in 0..2 {
55        for horizontal in 0..2 {
56            let weight = (if horizontal == 0 {
57                1.0 - fraction[0]
58            } else {
59                fraction[0]
60            }) * (if vertical == 0 {
61                1.0 - fraction[1]
62            } else {
63                fraction[1]
64            });
65            let texel = pattern.image.texel(
66                0,
67                address(column as i64 + horizontal, width),
68                address(row as i64 + vertical, height),
69            );
70            for channel in 0..4 {
71                result[channel] += f64::from(texel[channel]) * weight;
72            }
73        }
74    }
75    Ok(result)
76}
77
78pub(super) fn paint_sample(
79    paint: &Paint,
80    point: Point,
81    bounds: Rect,
82) -> Result<[f64; 4], ValidationError> {
83    let linear = match paint {
84        Paint::Solid(color) => color.to_linear_premultiplied(),
85        Paint::Linear(gradient) => gradient.sample(point, bounds)?,
86        Paint::Radial(gradient) => gradient.sample(point, bounds)?,
87        Paint::Sweep(gradient) => gradient.sample(point, bounds)?,
88        Paint::Pattern(pattern) => image_sample(pattern, point)?,
89        _ => return Err(invalid("paint", "Unsupported brush paint")),
90    };
91    let mut encoded = [0.0; 4];
92    encoded[3] = linear[3];
93    if linear[3] > 0.0 {
94        for channel in 0..3 {
95            encoded[channel] = motion_core::color::linear_to_srgb(linear[channel] / linear[3]);
96        }
97    }
98    Ok(encoded)
99}
100
101pub(super) fn material(
102    stroke: &BrushStroke,
103    bounds: Rect,
104) -> Result<Vec<Prepared>, ValidationError> {
105    let options = &stroke.advanced;
106    let groups = samples(stroke, 1.0)?;
107    let path = Arc::new(outline(stroke, &groups)?);
108    let segments: Vec<_> = groups
109        .iter()
110        .flat_map(|group| group.windows(2).map(|pair| (pair[0], pair[1])))
111        .collect();
112    // Boxes widened by the widest radius bound the distance from below, so blocks of
113    // far segments are skipped without changing the nearest result or the overlap count.
114    let boxes: Vec<_> = segments
115        .iter()
116        .map(|(first, second)| {
117            (
118                [
119                    first.point.x.min(second.point.x),
120                    first.point.y.min(second.point.y),
121                ],
122                [
123                    first.point.x.max(second.point.x),
124                    first.point.y.max(second.point.y),
125                ],
126                first.radius.max(second.radius),
127            )
128        })
129        .collect();
130    let blocks: Vec<_> = boxes
131        .chunks(MATERIAL_BLOCK)
132        .map(|chunk| {
133            chunk.iter().skip(1).fold(chunk[0], |total, item| {
134                (
135                    [total.0[0].min(item.0[0]), total.0[1].min(item.0[1])],
136                    [total.1[0].max(item.1[0]), total.1[1].max(item.1[1])],
137                    total.2.max(item.2),
138                )
139            })
140        })
141        .collect();
142    let length = stroke.path.try_length()?;
143    let width = bounds.width().ceil().max(1.0);
144    let height = bounds.height().ceil().max(1.0);
145    if width > 16384.0
146        || height > 16384.0
147        || width * height > 16_777_216.0
148        || (width * height) as u64 * segments.len() as u64 > stroke.material_work_limit
149    {
150        return Err(invalid(
151            "material",
152            "Extended material exceeds pixel or total evaluation allowance",
153        ));
154    }
155    let width = width as u32;
156    let height = height as u32;
157    let columns = width.div_ceil(options.tile_size);
158    let rows = height.div_ceil(options.tile_size);
159    if u64::from(columns) * u64::from(rows) > 256 {
160        return Err(invalid("material_tiles", "At most 256 material tiles"));
161    }
162    let paint = options.paint.clone().unwrap_or_else(|| stroke.color.into());
163    let ink = stroke.ink.unwrap_or_default();
164    let paper = options.paper.as_ref().map(|paper| {
165        ImagePattern::from_asset(paper.texture.clone())
166            .transform(Transform::scale(paper.grain_scale))
167            .extend(GradientExtend::Repeat)
168    });
169    let mut tiles = Vec::new();
170    for row in 0..rows {
171        for column in 0..columns {
172            let first = column * options.tile_size;
173            let top = row * options.tile_size;
174            let tile_width = (width - first).min(options.tile_size);
175            let tile_height = (height - top).min(options.tile_size);
176            let mut pixels = vec![[0.0; 4]; tile_width as usize * tile_height as usize];
177            let evaluate = |start: usize, pixels: &mut [[f64; 4]]| -> Result<(), ValidationError> {
178                for (offset, pixel) in pixels.iter_mut().enumerate() {
179                    let index = start + offset;
180                    let point = Point::new(
181                        bounds.left + f64::from(first) + (index % tile_width as usize) as f64 + 0.5,
182                        bounds.top + f64::from(top) + (index / tile_width as usize) as f64 + 0.5,
183                    );
184                    let lower_bound = |bounds: &([f64; 2], [f64; 2], f64)| {
185                        let horizontal =
186                            (bounds.0[0] - point.x).max(point.x - bounds.1[0]).max(0.0);
187                        let vertical = (bounds.0[1] - point.y).max(point.y - bounds.1[1]).max(0.0);
188                        libm::hypot(horizontal, vertical) - bounds.2
189                    };
190                    let measure = |index: usize| {
191                        let (first, second) = &segments[index];
192                        let delta = second.point - first.point;
193                        let square = delta.x * delta.x + delta.y * delta.y;
194                        let offset = point - first.point;
195                        let amount = if square > 0.0 {
196                            ((offset.x * delta.x + offset.y * delta.y) / square).clamp(0.0, 1.0)
197                        } else {
198                            0.0
199                        };
200                        let center = first.point + delta * amount;
201                        let offset = point - center;
202                        let angle = first.angle + (second.angle - first.angle) * amount;
203                        let local = rotated(offset.x, offset.y, -angle);
204                        let radius = first.radius + (second.radius - first.radius) * amount;
205                        let distance = libm::sqrt(
206                            local.x * local.x + local.y * local.y / (stroke.aspect * stroke.aspect),
207                        ) - radius;
208                        (
209                            distance,
210                            first.arc + (second.arc - first.arc) * amount,
211                            offset.x * first.tangent.y - offset.y * first.tangent.x,
212                            radius,
213                        )
214                    };
215                    // The best block seeds the nearest distance, so ties still resolve to the
216                    // lowest segment index and skipping becomes effective immediately.
217                    let mut nearest = (f64::INFINITY, 0.0, 0.0, 0.0);
218                    let mut nearest_index = usize::MAX;
219                    if let Some((block, _)) = blocks
220                        .iter()
221                        .enumerate()
222                        .map(|(block, bounds)| (block, lower_bound(bounds)))
223                        .min_by(|left, right| left.1.total_cmp(&right.1))
224                    {
225                        for index in block * MATERIAL_BLOCK
226                            ..((block + 1) * MATERIAL_BLOCK).min(segments.len())
227                        {
228                            let candidate = measure(index);
229                            if candidate.0 < nearest.0 {
230                                nearest = candidate;
231                                nearest_index = index;
232                            }
233                        }
234                    }
235                    let mut overlaps = 0usize;
236                    let mut previous_overlap = None;
237                    for (block, bounds) in blocks.iter().enumerate() {
238                        let lower = lower_bound(bounds);
239                        if lower > 1e-6 && lower > nearest.0 + 1e-6 {
240                            continue;
241                        }
242                        for index in block * MATERIAL_BLOCK
243                            ..((block + 1) * MATERIAL_BLOCK).min(segments.len())
244                        {
245                            let candidate = measure(index);
246                            if candidate.0 < 0.0
247                                && previous_overlap.is_none_or(|previous| index > previous + 1)
248                            {
249                                overlaps += 1;
250                                previous_overlap = Some(index);
251                            }
252                            if candidate.0 < nearest.0
253                                || (candidate.0 == nearest.0 && index < nearest_index)
254                            {
255                                nearest = candidate;
256                                nearest_index = index;
257                            }
258                        }
259                    }
260                    let paper_point = options.paper_transform.apply(point);
261                    let material = ink.sample((point.x, point.y), (paper_point.x, paper_point.y));
262                    let load = stroke.load[0]
263                        + (stroke.load[1] - stroke.load[0])
264                            * if length > 0.0 {
265                                nearest.1 / length
266                            } else {
267                                0.0
268                            };
269                    let mut alpha = material
270                        .shade(
271                            material.coverage(nearest.0, 1.0),
272                            nearest.0,
273                            (nearest.1, nearest.2 * options.anisotropy),
274                            load,
275                        )
276                        .clamp(0.0, 1.0);
277                    if let (Some(paper), Some(parameters)) = (&paper, &options.paper) {
278                        let sample = image_sample(paper, paper_point)?;
279                        let height = if sample[3] > 0.0 {
280                            (sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722)
281                                / sample[3]
282                        } else {
283                            0.0
284                        };
285                        alpha *= 1.0 - parameters.absorbency * height.clamp(0.0, 1.0);
286                    }
287                    if overlaps > 1 && options.build_up > 0.0 {
288                        alpha = 1.0
289                            - libm::pow(
290                                1.0 - alpha,
291                                1.0 + options.build_up * (overlaps - 1) as f64,
292                            );
293                    }
294                    let mut color = paint_sample(&paint, point, stroke.path.bounding_box())?;
295                    let rim = (1.0 - nearest.0.abs() / 2.0).clamp(0.0, 1.0);
296                    let darken = 1.0
297                        - options.edge_darkening * rim
298                        - options.build_up * (1.0 - 1.0 / overlaps.max(1) as f64) * 0.5;
299                    for channel in &mut color[..3] {
300                        *channel *= darken;
301                    }
302                    if options.height > 0.0 {
303                        let height = options.height
304                            * libm::sqrt((-nearest.0 / nearest.3.max(1e-12)).clamp(0.0, 1.0))
305                            * load
306                            * (1.0 + options.build_up * overlaps.saturating_sub(1) as f64);
307                        for channel in &mut color[..3] {
308                            *channel = motion_core::color::linear_to_srgb(height);
309                        }
310                    }
311                    color[3] *= alpha;
312                    *pixel = color;
313                }
314                Ok(())
315            };
316            if options.parallel {
317                let threads = std::thread::available_parallelism()
318                    .map_or(1, usize::from)
319                    .min(8);
320                let chunk = pixels.len().div_ceil(threads).max(1);
321                std::thread::scope(|scope| {
322                    let handles: Vec<_> = pixels
323                        .chunks_mut(chunk)
324                        .enumerate()
325                        .map(|(index, pixels)| {
326                            let evaluate = &evaluate;
327                            scope.spawn(move || evaluate(index * chunk, pixels))
328                        })
329                        .collect();
330                    for handle in handles {
331                        handle.join().map_err(|_| {
332                            invalid("material_thread", "Material worker panicked")
333                        })??;
334                    }
335                    Ok::<_, ValidationError>(())
336                })?;
337            } else {
338                evaluate(0, &mut pixels)?;
339            }
340            let image = ImageAsset::from_rgba_float(tile_width, tile_height, pixels)
341                .map_err(|error| invalid("material", &error.to_string()))?;
342            let mut pattern = ImagePattern::from_asset(Arc::new(image))
343                .transform(Transform::translate(
344                    bounds.left + f64::from(first),
345                    bounds.top + f64::from(top),
346                ))
347                .extend(GradientExtend::Pad)
348                .sampling(motion_core::Sampling::Bilinear);
349            pattern.decal = true;
350            tiles.push(Prepared {
351                path: path.clone(),
352                paint: pattern.into(),
353                bounds,
354            });
355        }
356    }
357    Ok(tiles)
358}