use valo_dl::{BlendMode, BlurStyle, ColorFilter, ImageFilter, MaskBlur};
use valo_geometry::{Color, Point, Rect};
use crate::frame::{PassColor, PlannedPass, TextureCopy};
use crate::images::IMAGE_FORMAT;
use crate::pipelines::{blur_style_id, Frag};
use crate::pool::FILTER_SIZE_BUCKET;
use super::emit::{
encode_color_filter, filter_quad_record, EncodedColorFilter, PAYLOAD_GEOM, PAYLOAD_MISC,
};
use super::layers::{layer_texture_size, LayerInfo};
use super::Planner;
#[derive(Default)]
pub(super) struct LayerEffects {
pub color_filter: Option<ColorFilter>,
pub image_filter: Option<ImageFilter>,
pub blur: Option<MaskBlur>,
pub subpass: bool,
pub image_basis: [f32; 4],
}
impl LayerEffects {
pub fn of(
paint: &valo_dl::Paint,
mask_scale: f32,
image_transform: &valo_geometry::Matrix,
subpass: bool,
) -> Self {
let [a, b, c, d, ..] = image_transform.to_affine();
Self {
color_filter: paint.color_filter,
image_filter: paint.effective_image_filter().cloned(),
blur: paint.mask_blur.map(|mask| MaskBlur {
sigma: (mask.sigma * mask_scale).max(0.05),
style: mask.style,
}),
subpass,
image_basis: [a, b, c, d],
}
}
pub fn is_empty(&self) -> bool {
self.color_filter.is_none() && self.image_filter.is_none() && self.blur.is_none()
}
}
pub(super) struct SharedBlur {
pub view: wgpu::TextureView,
pub region: Rect,
pub uv_max: [f32; 2],
pub sigma: f32,
pub source: wgpu::Texture,
}
#[derive(Clone)]
pub(super) struct FilteredTexture {
pub view: wgpu::TextureView,
pub uv_max: [f32; 2],
pub size: [u32; 2],
}
impl FilteredTexture {
pub fn source(view: wgpu::TextureView, size: [u32; 2], whole: &Rect) -> Self {
Self {
view,
uv_max: [whole.width / size[0] as f32, whole.height / size[1] as f32],
size,
}
}
}
pub(super) struct ColorFilterTarget {
pub view: wgpu::TextureView,
pub size: [u32; 2],
pub format: wgpu::TextureFormat,
}
impl Planner<'_> {
pub(super) fn composite_source(&mut self, info: &LayerInfo) -> (wgpu::TextureView, [f32; 4]) {
let size = layer_texture_size(&info.rect);
let sample = Rect::new(info.rect.x, info.rect.y, size[0] as f32, size[1] as f32);
if info.effects.is_empty() {
return (info.resolve.clone(), super::emit::full_rect_uv(&sample));
}
let whole = Rect::new(0.0, 0.0, size[0] as f32, size[1] as f32);
let filtered = if info.effects.subpass {
self.blur_then_recolour(info, size, &whole)
} else {
self.recolour_then_blur(info, size, &whole)
};
(filtered.view, region_uv(&sample, filtered.uv_max))
}
fn recolour_then_blur(
&mut self,
info: &LayerInfo,
size: [u32; 2],
whole: &Rect,
) -> FilteredTexture {
if info.effects.image_filter.is_none() {
return self.recolour_then_mask_blur(info, size, whole);
}
let mut output = FilteredTexture::source(info.resolve.clone(), size, whole);
if let Some(filter) = info.effects.color_filter {
output = self.push_color_filter_input(&output, whole, filter);
}
if let Some(filter) = &info.effects.image_filter.clone() {
output = self.push_image_filter(&output, whole, filter, info.effects.image_basis);
}
if let Some(mask) = info.effects.blur {
output = self.blur_filtered_layer(&output, whole, mask);
}
output
}
fn blur_then_recolour(
&mut self,
info: &LayerInfo,
size: [u32; 2],
whole: &Rect,
) -> FilteredTexture {
if info.effects.image_filter.is_none() {
return self.mask_blur_then_recolour(info, size, whole);
}
let mut output = FilteredTexture::source(info.resolve.clone(), size, whole);
if let Some(filter) = &info.effects.image_filter.clone() {
output = self.push_image_filter(&output, whole, filter, info.effects.image_basis);
}
if let Some(mask) = info.effects.blur {
output = self.blur_filtered_layer(&output, whole, mask);
}
if let Some(filter) = info.effects.color_filter {
output = self.push_color_filter_input(&output, whole, filter);
}
output
}
fn recolour_then_mask_blur(
&mut self,
info: &LayerInfo,
size: [u32; 2],
whole: &Rect,
) -> FilteredTexture {
let recoloured = info
.effects
.color_filter
.map(|filter| self.push_color_filter(&info.resolve, size, whole, filter));
let (sharp, sharp_size) = recoloured.as_ref().map_or_else(
|| (info.resolve.clone(), size),
|output| (output.view.clone(), output.size),
);
match info.effects.blur {
None => recoloured.expect("empty effects returned early"),
Some(mask) => self.blur_layer(&sharp, sharp_size, whole, mask),
}
}
fn mask_blur_then_recolour(
&mut self,
info: &LayerInfo,
size: [u32; 2],
whole: &Rect,
) -> FilteredTexture {
let blurred = info
.effects
.blur
.map(|mask| self.blur_layer(&info.resolve, size, whole, mask));
let Some(filter) = info.effects.color_filter else {
return blurred.expect("empty effects returned early");
};
match blurred {
Some(blurred) => self.push_color_filter_input(&blurred, whole, filter),
None => self.push_color_filter(&info.resolve, size, whole, filter),
}
}
fn blur_layer(
&mut self,
source: &wgpu::TextureView,
size: [u32; 2],
whole: &Rect,
mask: MaskBlur,
) -> FilteredTexture {
let blurred = self.plan_blur(source, size, whole, mask.sigma, Vec::new());
match mask.style {
BlurStyle::Normal => blurred,
style => self.push_mask_combine(&blurred, source, whole, style),
}
}
fn blur_filtered_layer(
&mut self,
source: &FilteredTexture,
whole: &Rect,
mask: MaskBlur,
) -> FilteredTexture {
let blurred = self.plan_blur_input(source, whole, mask.sigma, mask.sigma);
match mask.style {
BlurStyle::Normal => blurred,
style => {
let sharp = self.materialize_filter_input(source, whole);
self.push_mask_combine(&blurred, &sharp.view, whole, style)
}
}
}
pub(super) fn blur_of_target_region(&mut self, region: &Rect, sigma: f32) -> FilteredTexture {
self.emit_segment();
self.stats.snapshots += 1;
let (size, origin, src) = {
let frame = self.frame();
(frame.size, frame.origin, frame.src_texture.clone())
};
let snapshot = self.pool.take_snapshot(size, self.format);
let mut copies = Vec::new();
if let Some((copy_origin, extent)) = super::segments::snapshot_region(region, origin, size)
{
copies.push(TextureCopy {
src,
dst: snapshot.texture.clone(),
origin: copy_origin,
size: extent,
});
}
let local = Rect::new(
region.x - origin.x,
region.y - origin.y,
region.width,
region.height,
);
self.plan_blur(&snapshot.view, size, &local, sigma, copies)
}
pub(super) fn plan_blur(
&mut self,
source: &wgpu::TextureView,
source_size: [u32; 2],
region: &Rect,
sigma: f32,
pre_copies: Vec<TextureCopy>,
) -> FilteredTexture {
let scale = blur_scale(sigma);
let work = [
(region.width * scale).round().max(1.0),
(region.height * scale).round().max(1.0),
];
let mut copies = pre_copies;
let mut src = source.clone();
let mut src_px = [source_size[0] as f32, source_size[1] as f32];
let mut src_uv_max: Option<[f32; 2]> = None;
let mut cur = [region.width, region.height];
while scale < 1.0 && (cur[0] > work[0] || cur[1] > work[1]) {
let next = [
(cur[0] * 0.5).max(work[0]).round().max(1.0),
(cur[1] * 0.5).max(work[1]).round().max(1.0),
];
let uv = match src_uv_max {
None => source_region_uv(region, source_size, next),
Some(uv_max) => resample_uv(uv_max, next),
};
let (view, bucket) =
self.push_filter_pass(&src, uv, next, 0.0, [0.0, 0.0], std::mem::take(&mut copies));
src = view;
src_px = bucket;
src_uv_max = Some([next[0] / bucket[0], next[1] / bucket[1]]);
cur = next;
}
let work_sigma = sigma * scale;
let src_uv = match src_uv_max {
None => source_region_uv(region, source_size, work),
Some(uv_max) => resample_uv(uv_max, work),
};
let (h_view, h_bucket) = self.push_filter_pass(
&src,
src_uv,
work,
work_sigma,
[1.0 / src_px[0], 0.0],
std::mem::take(&mut copies),
);
let (v_view, v_bucket) = self.push_filter_pass(
&h_view,
corner_uv(h_bucket),
work,
work_sigma,
[0.0, 1.0 / h_bucket[1]],
Vec::new(),
);
FilteredTexture {
view: v_view,
uv_max: [work[0] / v_bucket[0], work[1] / v_bucket[1]],
size: [v_bucket[0] as u32, v_bucket[1] as u32],
}
}
fn plan_blur_input(
&mut self,
source: &FilteredTexture,
whole: &Rect,
sigma_x: f32,
sigma_y: f32,
) -> FilteredTexture {
let scale_x = blur_scale(sigma_x);
let scale_y = blur_scale(sigma_y);
let work = [
(whole.width * scale_x).round().max(1.0),
(whole.height * scale_y).round().max(1.0),
];
let mut source_view = source.view.clone();
let mut source_pixels = [source.size[0] as f32, source.size[1] as f32];
let mut source_uv_max = source.uv_max;
let mut current = [whole.width, whole.height];
while current[0] > work[0] || current[1] > work[1] {
let next = [
(current[0] * 0.5).max(work[0]).round().max(1.0),
(current[1] * 0.5).max(work[1]).round().max(1.0),
];
let (view, bucket) = self.push_filter_pass(
&source_view,
resample_uv(source_uv_max, next),
next,
0.0,
[0.0, 0.0],
Vec::new(),
);
source_view = view;
source_pixels = bucket;
source_uv_max = [next[0] / bucket[0], next[1] / bucket[1]];
current = next;
}
let (horizontal, horizontal_bucket) = self.push_filter_pass(
&source_view,
resample_uv(source_uv_max, work),
work,
sigma_x * scale_x,
[1.0 / source_pixels[0], 0.0],
Vec::new(),
);
let (vertical, vertical_bucket) = self.push_filter_pass(
&horizontal,
corner_uv(horizontal_bucket),
work,
sigma_y * scale_y,
[0.0, 1.0 / horizontal_bucket[1]],
Vec::new(),
);
FilteredTexture {
view: vertical,
uv_max: [work[0] / vertical_bucket[0], work[1] / vertical_bucket[1]],
size: [vertical_bucket[0] as u32, vertical_bucket[1] as u32],
}
}
fn push_image_filter(
&mut self,
source: &FilteredTexture,
whole: &Rect,
filter: &ImageFilter,
basis: [f32; 4],
) -> FilteredTexture {
let mut stages = Vec::new();
image_filter_stages(filter, &mut stages);
let mut output = source.clone();
let mut index = 0;
while index < stages.len() {
match stages[index] {
ImageFilter::Color(filter) => {
output = self.push_color_filter_input(&output, whole, *filter);
index += 1;
}
ImageFilter::Blur { .. } => {
let mut sigma_x_squared = 0.0;
let mut sigma_y_squared = 0.0;
while let Some(ImageFilter::Blur { sigma_x, sigma_y }) = stages.get(index) {
let [device_x, device_y] = device_sigma(basis, *sigma_x, *sigma_y);
sigma_x_squared += device_x * device_x;
sigma_y_squared += device_y * device_y;
index += 1;
}
let (sigma_x, sigma_y) = (sigma_x_squared.sqrt(), sigma_y_squared.sqrt());
if sigma_x <= 0.0 && sigma_y <= 0.0 {
continue;
}
output = self.plan_blur_input(&output, whole, sigma_x, sigma_y);
}
ImageFilter::DropShadow {
offset,
sigma_x,
sigma_y,
color,
} => {
output = self.plan_drop_shadow(
&output,
whole,
*offset,
[*sigma_x, *sigma_y],
*color,
basis,
);
index += 1;
}
ImageFilter::Compose { .. } => unreachable!("composition was flattened"),
}
}
output
}
fn materialize_filter_input(
&mut self,
source: &FilteredTexture,
whole: &Rect,
) -> FilteredTexture {
let expected = [
whole.width / source.size[0] as f32,
whole.height / source.size[1] as f32,
];
if (source.uv_max[0] - expected[0]).abs() < 1e-6
&& (source.uv_max[1] - expected[1]).abs() < 1e-6
{
return source.clone();
}
let work = [whole.width, whole.height];
let (view, bucket) = self.push_filter_pass(
&source.view,
region_uv(whole, source.uv_max),
work,
0.0,
[0.0, 0.0],
Vec::new(),
);
FilteredTexture {
view,
uv_max: [work[0] / bucket[0], work[1] / bucket[1]],
size: [bucket[0] as u32, bucket[1] as u32],
}
}
fn push_filter_pass(
&mut self,
source: &wgpu::TextureView,
source_uv: [f32; 4],
work: [f32; 2],
sigma: f32,
step: [f32; 2],
pre_copies: Vec<TextureCopy>,
) -> (wgpu::TextureView, [f32; 2]) {
let extent = [exact_extent(work[0]), exact_extent(work[1])];
let target = self.pool.take_filter(extent, self.format);
let quad = Rect::new(0.0, 0.0, work[0], work[1]);
let radius = if sigma > 0.0 {
(sigma * 2.5).ceil().min(48.0)
} else {
0.0
};
let mut record = filter_quad_record(&quad, extent);
record.set_payload(PAYLOAD_GEOM, source_uv);
record.set_payload(PAYLOAD_MISC, [sigma, radius, step[0], step[1]]);
let bind = self.emit.texture_bind(source);
self.push_filter(target.view.clone(), Frag::Blur, record, bind, pre_copies);
(target.view, [extent[0] as f32, extent[1] as f32])
}
fn push_color_filter(
&mut self,
source: &wgpu::TextureView,
source_size: [u32; 2],
whole: &Rect,
filter: ColorFilter,
) -> FilteredTexture {
let work = [whole.width, whole.height];
let bucket = [filter_bucket(work[0]), filter_bucket(work[1])];
let target = self.pool.take_filter(bucket, self.format);
self.push_color_filter_to(
source,
source_size,
whole,
filter,
ColorFilterTarget {
view: target.view.clone(),
size: bucket,
format: self.format,
},
);
FilteredTexture {
view: target.view,
uv_max: [work[0] / bucket[0] as f32, work[1] / bucket[1] as f32],
size: bucket,
}
}
fn push_color_filter_input(
&mut self,
source: &FilteredTexture,
whole: &Rect,
filter: ColorFilter,
) -> FilteredTexture {
let work = [whole.width, whole.height];
let bucket = [filter_bucket(work[0]), filter_bucket(work[1])];
let target = self.pool.take_filter(bucket, self.format);
let mut record = filter_quad_record(whole, bucket);
record.set_payload(PAYLOAD_GEOM, region_uv(whole, source.uv_max));
let fragment = match encode_color_filter(&mut record, filter) {
EncodedColorFilter::Matrix => Frag::ColorMatrix,
EncodedColorFilter::Blend => Frag::ColorBlend,
};
let bind = self.emit.texture_bind(&source.view);
self.push_filter(target.view.clone(), fragment, record, bind, Vec::new());
FilteredTexture {
view: target.view,
uv_max: [work[0] / bucket[0] as f32, work[1] / bucket[1] as f32],
size: bucket,
}
}
pub(super) fn push_color_filter_to(
&mut self,
source: &wgpu::TextureView,
source_size: [u32; 2],
whole: &Rect,
filter: ColorFilter,
target: ColorFilterTarget,
) {
let mut record = filter_quad_record(whole, target.size);
record.set_payload(
PAYLOAD_GEOM,
source_region_uv(whole, source_size, [whole.width, whole.height]),
);
let frag = match encode_color_filter(&mut record, filter) {
EncodedColorFilter::Matrix => Frag::ColorMatrix,
EncodedColorFilter::Blend => Frag::ColorBlend,
};
let bind = self.emit.texture_bind(source);
self.push_filter_with_format(target.view, target.format, frag, record, bind, Vec::new());
}
fn plan_drop_shadow(
&mut self,
source: &FilteredTexture,
whole: &Rect,
offset: Point,
sigma: [f32; 2],
color: Color,
basis: [f32; 4],
) -> FilteredTexture {
let tint = ColorFilter::Blend(color, BlendMode::SrcIn);
let tinted = self.push_color_filter_input(source, whole, tint);
let [device_x, device_y] = skia_sigma(basis, sigma[0], sigma[1]);
let shadow = if device_x > 0.0 || device_y > 0.0 {
self.plan_blur_input(&tinted, whole, device_x, device_y)
} else {
tinted
};
self.push_drop_shadow_combine(&shadow, source, whole, device_offset(basis, offset))
}
fn push_drop_shadow_combine(
&mut self,
shadow: &FilteredTexture,
sharp: &FilteredTexture,
whole: &Rect,
offset: [f32; 2],
) -> FilteredTexture {
let work = [whole.width, whole.height];
let bucket = [filter_bucket(work[0]), filter_bucket(work[1])];
let target = self.pool.take_filter(bucket, self.format);
let mut record = filter_quad_record(whole, bucket);
record.set_payload(
PAYLOAD_GEOM,
[
shadow.uv_max[0] / work[0],
shadow.uv_max[1] / work[1],
sharp.uv_max[0] / work[0],
sharp.uv_max[1] / work[1],
],
);
record.set_payload(
PAYLOAD_MISC,
[offset[0], offset[1], 1.0 / work[0], 1.0 / work[1]],
);
let bind = self.emit.blend_bind(&shadow.view, &sharp.view);
self.push_filter(
target.view.clone(),
Frag::DropShadow,
record,
bind,
Vec::new(),
);
FilteredTexture {
view: target.view,
uv_max: [work[0] / bucket[0] as f32, work[1] / bucket[1] as f32],
size: bucket,
}
}
fn push_mask_combine(
&mut self,
blur: &FilteredTexture,
sharp: &wgpu::TextureView,
whole: &Rect,
style: BlurStyle,
) -> FilteredTexture {
let work = [whole.width, whole.height];
let bucket = [filter_bucket(work[0]), filter_bucket(work[1])];
let target = self.pool.take_filter(bucket, self.format);
let mut record = filter_quad_record(whole, bucket);
record.set_payload(
PAYLOAD_GEOM,
[blur.uv_max[0] / work[0], blur.uv_max[1] / work[1], 0.0, 0.0],
);
record.set_payload(
PAYLOAD_MISC,
[
blur_style_id(style) as f32,
0.0,
1.0 / work[0],
1.0 / work[1],
],
);
let bind = self.emit.blend_bind(&blur.view, sharp);
self.push_filter(
target.view.clone(),
Frag::MaskCombine,
record,
bind,
Vec::new(),
);
FilteredTexture {
view: target.view,
uv_max: [work[0] / bucket[0] as f32, work[1] / bucket[1] as f32],
size: bucket,
}
}
fn push_filter(
&mut self,
target: wgpu::TextureView,
frag: Frag,
record: super::emit::UniformRecord,
bind: wgpu::BindGroup,
pre_copies: Vec<TextureCopy>,
) {
self.push_filter_with_format(target, self.format, frag, record, bind, pre_copies);
}
fn push_filter_with_format(
&mut self,
target: wgpu::TextureView,
target_format: wgpu::TextureFormat,
frag: Frag,
record: super::emit::UniformRecord,
bind: wgpu::BindGroup,
pre_copies: Vec<TextureCopy>,
) {
let step = self.emit.filter_step(target_format, frag, record, bind);
self.passes.push(PlannedPass {
color: PassColor::Filter { view: target },
depth: None,
clear: Some(Color::TRANSPARENT),
clear_depth: false,
store: true, pre_copies,
steps: vec![step],
});
self.stats.filter_passes += 1;
}
pub(super) fn filtered_image(
&mut self,
source: &valo_dl::Image,
filter: ColorFilter,
) -> valo_dl::Image {
let (filtered, created) = self.emit.filtered_image_entry(source, filter);
if created {
self.emit_segment();
let whole = Rect::new(0.0, 0.0, source.width(), source.height());
self.push_color_filter_to(
source.view(),
source.size(),
&whole,
filter,
ColorFilterTarget {
view: filtered.view().clone(),
size: source.size(),
format: IMAGE_FORMAT,
},
);
}
filtered
}
}
fn blur_scale(sigma: f32) -> f32 {
if sigma <= 4.0 {
return 1.0;
}
(4.0 / sigma).log2().round().exp2()
}
fn filter_bucket(px: f32) -> u32 {
(px.ceil().max(1.0) as u32).div_ceil(FILTER_SIZE_BUCKET) * FILTER_SIZE_BUCKET
}
fn exact_extent(px: f32) -> u32 {
px.ceil().max(1.0) as u32
}
fn source_region_uv(region: &Rect, source_size: [u32; 2], work: [f32; 2]) -> [f32; 4] {
let sw = source_size[0] as f32;
let sh = source_size[1] as f32;
[
region.width / (sw * work[0]),
region.height / (sh * work[1]),
region.x / sw,
region.y / sh,
]
}
fn corner_uv(bucket: [f32; 2]) -> [f32; 4] {
[1.0 / bucket[0], 1.0 / bucket[1], 0.0, 0.0]
}
fn resample_uv(source_uv_max: [f32; 2], work: [f32; 2]) -> [f32; 4] {
[
source_uv_max[0] / work[0],
source_uv_max[1] / work[1],
0.0,
0.0,
]
}
fn image_filter_stages<'a>(filter: &'a ImageFilter, stages: &mut Vec<&'a ImageFilter>) {
match filter {
ImageFilter::Compose { outer, inner } => {
image_filter_stages(inner, stages);
image_filter_stages(outer, stages);
}
stage => stages.push(stage),
}
}
fn device_sigma(basis: [f32; 4], sigma_x: f32, sigma_y: f32) -> [f32; 2] {
let [a, b, c, d] = basis;
[
(sigma_x * a + sigma_y * c).abs(),
(sigma_x * b + sigma_y * d).abs(),
]
}
fn skia_sigma(basis: [f32; 4], sigma_x: f32, sigma_y: f32) -> [f32; 2] {
let [a, b, c, d] = basis;
[sigma_x * a.hypot(b), sigma_y * c.hypot(d)]
}
fn device_offset(basis: [f32; 4], offset: Point) -> [f32; 2] {
let [a, b, c, d] = basis;
[offset.x * a + offset.y * c, offset.x * b + offset.y * d]
}
pub(super) fn region_uv(region: &Rect, uv_max: [f32; 2]) -> [f32; 4] {
let sx = uv_max[0] / region.width.max(1e-6);
let sy = uv_max[1] / region.height.max(1e-6);
[sx, sy, -region.x * sx, -region.y * sy]
}
#[cfg(test)]
mod tests {
use super::{device_sigma, skia_sigma};
use valo_geometry::Matrix;
fn basis_of(matrix: &Matrix) -> [f32; 4] {
let [a, b, c, d, ..] = matrix.to_affine();
[a, b, c, d]
}
fn assert_sigma(actual: [f32; 2], expected: [f32; 2]) {
assert!(
(actual[0] - expected[0]).abs() < 1e-4 && (actual[1] - expected[1]).abs() < 1e-4,
"sigma {actual:?} != {expected:?}"
);
}
#[test]
fn skia_sigma_keeps_a_rotated_blur_round() {
let basis = basis_of(&Matrix::rotation(std::f32::consts::FRAC_PI_4));
assert_sigma(skia_sigma(basis, 10.0, 10.0), [10.0, 10.0]);
assert_sigma(device_sigma(basis, 10.0, 10.0), [0.0, 14.142136]);
}
#[test]
fn skia_sigma_still_scales_each_axis() {
let basis = basis_of(&Matrix::scale(2.0, 3.0));
assert_sigma(skia_sigma(basis, 4.0, 5.0), [8.0, 15.0]);
}
#[test]
fn device_sigma_scales_each_axis() {
let basis = basis_of(&Matrix::scale(2.0, 3.0));
assert_sigma(device_sigma(basis, 4.0, 5.0), [8.0, 15.0]);
}
#[test]
fn device_sigma_swaps_axes_under_a_quarter_turn() {
let basis = basis_of(&Matrix::rotation(std::f32::consts::FRAC_PI_2));
assert_sigma(device_sigma(basis, 12.0, 3.0), [3.0, 12.0]);
}
#[test]
fn device_sigma_is_sign_agnostic() {
let basis = basis_of(&Matrix::rotation(std::f32::consts::PI));
assert_sigma(device_sigma(basis, 7.0, 2.0), [7.0, 2.0]);
}
#[test]
fn device_sigma_mixes_axes_under_rotation_and_scale() {
let matrix = Matrix::scale(2.0, 3.0).then(&Matrix::rotation(std::f32::consts::FRAC_PI_4));
let [a, b, c, d, ..] = matrix.to_affine();
let expected = [(6.0 * a + 4.0 * c).abs(), (6.0 * b + 4.0 * d).abs()];
assert_sigma(device_sigma(basis_of(&matrix), 6.0, 4.0), expected);
assert!(expected[0] > 1.0 && expected[1] > 1.0);
}
#[test]
fn device_sigma_ignores_a_mirror() {
let mirrored = basis_of(&Matrix::scale(-2.0, 3.0));
assert_sigma(device_sigma(mirrored, 4.0, 5.0), [8.0, 15.0]);
}
#[test]
fn composed_blurs_transform_before_they_combine() {
let basis = basis_of(&Matrix::rotation(std::f32::consts::FRAC_PI_4));
let stages = [(10.0f32, 0.0f32), (0.0f32, 10.0f32)];
let (mut x_squared, mut y_squared) = (0.0, 0.0);
for (sigma_x, sigma_y) in stages {
let [x, y] = device_sigma(basis, sigma_x, sigma_y);
x_squared += x * x;
y_squared += y * y;
}
assert_sigma([x_squared.sqrt(), y_squared.sqrt()], [10.0, 10.0]);
let combined = device_sigma(basis, 10.0, 10.0);
assert!(
combined[0] < 0.001 && combined[1] > 14.0,
"combining first must be the WRONG answer, got {combined:?}"
);
}
}