use crate::copy::GpuCopyInstance;
use crate::schedule::round::FilterOp;
use crate::util::pack_u16_pair;
use alloc::vec::Vec;
use bytemuck::{Pod, Zeroable};
use vello_common::filter::drop_shadow::DropShadow;
use vello_common::filter::flood::Flood;
use vello_common::filter::gaussian_blur::{DecimationSizer, GaussianBlur, MAX_KERNEL_SIZE};
use vello_common::filter::offset::Offset;
use vello_common::filter::{FilterData, PreparedFilter};
use vello_common::filter_effects::EdgeMode;
use vello_common::geometry::{RectU16, SizeU16};
use vello_common::util::RetainVec;
#[expect(clippy::cast_possible_truncation, reason = "safe in this case")]
pub(crate) const FILTER_ATLAS_PADDING: u16 = MAX_KERNEL_SIZE as u16 / 2;
const BYTES_PER_TEXEL: usize = 16;
const FILTER_SIZE_BYTES: usize = 48;
const FILTER_SIZE_U32: usize = FILTER_SIZE_BYTES / 4;
const COMPOSITE_ORIGINAL_SHIFT: u32 = 13;
const COMPOSITE_ORIGINAL_MASK: u32 = 1 << COMPOSITE_ORIGINAL_SHIFT;
const _: () = assert!(
size_of::<GpuFilterData>() == FILTER_SIZE_BYTES,
"memory size of filters need to match"
);
const _: () = assert!(
size_of::<GpuOffset>() == FILTER_SIZE_BYTES,
"memory size of filters need to match"
);
const _: () = assert!(
size_of::<GpuFlood>() == FILTER_SIZE_BYTES,
"memory size of filters need to match"
);
const _: () = assert!(
size_of::<GpuDropShadow>() == FILTER_SIZE_BYTES,
"memory size of filters need to match"
);
const _: () = assert!(
size_of::<GpuGaussianBlur>() == FILTER_SIZE_BYTES,
"memory size of filters need to match"
);
pub(crate) mod filter_type {
pub(crate) const OFFSET: u32 = 0;
pub(crate) const FLOOD: u32 = 1;
pub(crate) const GAUSSIAN_BLUR: u32 = 2;
pub(crate) const DROP_SHADOW: u32 = 3;
}
pub(crate) mod edge_mode {
pub(crate) const DUPLICATE: u32 = 0;
pub(crate) const WRAP: u32 = 1;
pub(crate) const MIRROR: u32 = 2;
pub(crate) const NONE: u32 = 3;
}
pub(crate) mod pass_kind {
pub(crate) const COPY: u32 = 0;
pub(crate) const FLOOD: u32 = 1;
pub(crate) const OFFSET: u32 = 2;
pub(crate) const DOWNSCALE: u32 = 3;
pub(crate) const BLUR_H: u32 = 4;
pub(crate) const BLUR_V: u32 = 5;
pub(crate) const UPSCALE: u32 = 6;
pub(crate) const COMPOSITE_DROP_SHADOW: u32 = 7;
pub(crate) const COLORIZE: u32 = 8;
}
pub(crate) fn edge_mode_to_gpu(mode: EdgeMode) -> u32 {
match mode {
EdgeMode::Duplicate => edge_mode::DUPLICATE,
EdgeMode::Wrap => edge_mode::WRAP,
EdgeMode::Mirror => edge_mode::MIRROR,
EdgeMode::None => edge_mode::NONE,
}
}
fn pack_header(filter_type: u32) -> u32 {
debug_assert!(filter_type <= 31, "filter_type must fit in 5 bits");
filter_type
}
const fn pack_header_with_gaussian_params(
filter_type: u32,
edge_mode: u32,
n_decimations: u32,
n_linear_taps: u32,
) -> u32 {
debug_assert!(filter_type <= 31, "filter_type must fit in 5 bits");
debug_assert!(edge_mode <= 3, "edge_mode must fit in 2 bits");
debug_assert!(n_decimations <= 15, "n_decimations must fit in 4 bits");
debug_assert!(n_linear_taps <= 3, "n_linear_taps must fit in 2 bits");
filter_type | (edge_mode << 5) | (n_decimations << 7) | (n_linear_taps << 11)
}
const _: () = assert!(
pack_header_with_gaussian_params(31, 3, 15, 3) & COMPOSITE_ORIGINAL_MASK == 0,
"Gaussian filter parameters overlap the composite_original bit"
);
const MAX_TAPS_PER_SIDE: usize = (MAX_KERNEL_SIZE / 2).div_ceil(2);
struct LinearKernel {
center_weight: f32,
weights: [f32; MAX_TAPS_PER_SIDE],
offsets: [f32; MAX_TAPS_PER_SIDE],
n_taps: u8,
}
impl LinearKernel {
fn new(kernel: &[f32; MAX_KERNEL_SIZE], kernel_size: u8) -> Self {
let kernel_size = kernel_size as usize;
let radius = kernel_size / 2;
let center_weight = kernel[radius];
let mut weights = [0.0_f32; MAX_TAPS_PER_SIDE];
let mut offsets = [0.0_f32; MAX_TAPS_PER_SIDE];
let mut n_taps = 0_u8;
let positive_side = &kernel[radius + 1..kernel_size];
let (pairs, remainder) = positive_side.as_chunks::<2>();
for (k, &[w1, w2]) in pairs.iter().enumerate() {
let merged_weight = w1 + w2;
let offset1 = (2 * k + 1) as f32;
let merged_offset = if merged_weight > 0.0 {
(w1 * offset1 + w2 * (offset1 + 1.0)) / merged_weight
} else {
offset1
};
weights[n_taps as usize] = merged_weight;
offsets[n_taps as usize] = merged_offset;
n_taps += 1;
}
if let [leftover] = remainder {
weights[n_taps as usize] = *leftover;
offsets[n_taps as usize] = radius as f32;
n_taps += 1;
}
Self {
center_weight,
weights,
offsets,
n_taps,
}
}
}
#[repr(C, align(16))]
#[derive(Debug, Clone, Copy, PartialEq, Zeroable, Pod)]
pub(crate) struct GpuOffset {
pub header: u32,
pub dx: f32,
pub dy: f32,
pub _padding: [u32; 9],
}
impl From<&Offset> for GpuOffset {
fn from(offset: &Offset) -> Self {
Self {
header: pack_header(filter_type::OFFSET),
dx: offset.dx,
dy: offset.dy,
_padding: [0; 9],
}
}
}
#[repr(C, align(16))]
#[derive(Debug, Clone, Copy, PartialEq, Zeroable, Pod)]
pub(crate) struct GpuFlood {
pub header: u32,
pub color: u32,
pub _padding: [u32; 10],
}
impl From<&Flood> for GpuFlood {
fn from(flood: &Flood) -> Self {
Self {
header: pack_header(filter_type::FLOOD),
color: flood.color.premultiply().to_rgba8().to_u32(),
_padding: [0; 10],
}
}
}
#[repr(C, align(16))]
#[derive(Debug, Clone, Copy, PartialEq, Zeroable, Pod)]
pub(crate) struct GpuGaussianBlur {
pub header: u32,
pub center_weight: f32,
pub linear_weights: [f32; MAX_TAPS_PER_SIDE],
pub linear_offsets: [f32; MAX_TAPS_PER_SIDE],
pub _padding: [u32; 4],
}
impl From<&GaussianBlur> for GpuGaussianBlur {
#[expect(
clippy::cast_possible_truncation,
reason = "n_decimations fits in 4 bits"
)]
fn from(blur: &GaussianBlur) -> Self {
let lk = LinearKernel::new(&blur.kernel, blur.kernel_size);
Self {
header: pack_header_with_gaussian_params(
filter_type::GAUSSIAN_BLUR,
edge_mode_to_gpu(blur.edge_mode),
blur.n_decimations as u32,
lk.n_taps as u32,
),
center_weight: lk.center_weight,
linear_weights: lk.weights,
linear_offsets: lk.offsets,
_padding: [0; 4],
}
}
}
#[repr(C, align(16))]
#[derive(Debug, Clone, Copy, PartialEq, Zeroable, Pod)]
pub(crate) struct GpuDropShadow {
pub header: u32,
pub center_weight: f32,
pub linear_weights: [f32; MAX_TAPS_PER_SIDE],
pub linear_offsets: [f32; MAX_TAPS_PER_SIDE],
pub dx: f32,
pub dy: f32,
pub color: u32,
pub _padding: [u32; 1],
}
impl From<&DropShadow> for GpuDropShadow {
#[expect(
clippy::cast_possible_truncation,
reason = "n_decimations fits in 4 bits"
)]
fn from(shadow: &DropShadow) -> Self {
let lk = LinearKernel::new(&shadow.kernel, shadow.kernel_size);
let composite_original = if shadow.composite_original {
COMPOSITE_ORIGINAL_MASK
} else {
0
};
Self {
header: pack_header_with_gaussian_params(
filter_type::DROP_SHADOW,
edge_mode_to_gpu(shadow.edge_mode),
shadow.n_decimations as u32,
lk.n_taps as u32,
) | composite_original,
center_weight: lk.center_weight,
linear_weights: lk.weights,
linear_offsets: lk.offsets,
dx: shadow.dx,
dy: shadow.dy,
color: shadow.color.premultiply().to_rgba8().to_u32(),
_padding: [0; 1],
}
}
}
#[repr(C, align(16))]
#[derive(Debug, Clone, Copy, Zeroable, Pod)]
pub(crate) struct GpuFilterData {
data: [u32; FILTER_SIZE_U32],
}
impl GpuFilterData {
#[expect(
clippy::cast_possible_truncation,
reason = "filter size is a small constant"
)]
pub(crate) const SIZE_TEXELS: u32 = size_of::<Self>().div_ceil(BYTES_PER_TEXEL) as u32;
pub(crate) fn filter_type(&self) -> u32 {
self.data[0] & 0x1F
}
pub(crate) fn n_decimations(&self) -> usize {
((self.data[0] >> 7) & 0xF) as usize
}
pub(crate) fn composite_original(&self) -> bool {
self.data[0] & COMPOSITE_ORIGINAL_MASK != 0
}
pub(crate) fn needs_copy_pass(&self) -> bool {
self.filter_type() == filter_type::DROP_SHADOW && self.composite_original()
}
}
trait CastToFilterData: Pod {}
impl CastToFilterData for GpuOffset {}
impl CastToFilterData for GpuFlood {}
impl CastToFilterData for GpuGaussianBlur {}
impl CastToFilterData for GpuDropShadow {}
impl<T: CastToFilterData> From<T> for GpuFilterData {
fn from(filter: T) -> Self {
bytemuck::cast(filter)
}
}
impl From<&PreparedFilter> for GpuFilterData {
fn from(filter: &PreparedFilter) -> Self {
match filter {
PreparedFilter::Offset(f) => GpuOffset::from(f).into(),
PreparedFilter::Flood(f) => GpuFlood::from(f).into(),
PreparedFilter::GaussianBlur(f) => GpuGaussianBlur::from(f).into(),
PreparedFilter::DropShadow(f) => GpuDropShadow::from(f).into(),
}
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub(crate) struct FilterInstanceData {
pub source_origin: u32,
pub source_size: u32,
pub dest_origin: u32,
pub dest_size: u32,
pub dest_texture_size: u32,
pub filter_data_offset: u32,
pub original_origin: u32,
pub original_size: u32,
pub filter_pass_kind: u32,
}
#[derive(Debug, Default)]
pub(crate) struct FilterContext {
filters: Vec<GpuFilterData>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct PreparedGpuFilter {
pub(crate) data_offset: u32,
pub(crate) data: GpuFilterData,
}
#[derive(Debug, Default)]
pub(crate) struct FilterPassPlan {
copy_pass: Vec<GpuCopyInstance>,
steps: RetainVec<Vec<FilterInstanceData>>,
}
impl FilterPassPlan {
pub(crate) fn init(
&mut self,
filters: impl IntoIterator<Item = FilterOp>,
texture_size: SizeU16,
) {
self.clear();
for filter in filters {
let mut builder = FilterPassBuilder::new(filter, texture_size, self);
if filter.gpu_filter.needs_copy_pass() {
builder.push_copy_to_scratch_pass();
}
match filter.gpu_filter.filter_type() {
filter_type::OFFSET => {
builder.emit(pass_kind::OFFSET);
}
filter_type::FLOOD => {
builder.emit(pass_kind::FLOOD);
}
filter_type::GAUSSIAN_BLUR => {
builder.emit_blur_sequence(filter.gpu_filter.n_decimations());
}
filter_type::DROP_SHADOW => {
builder.emit(pass_kind::OFFSET);
builder.emit_blur_sequence(filter.gpu_filter.n_decimations());
if filter.gpu_filter.composite_original() {
builder.emit(pass_kind::COMPOSITE_DROP_SHADOW);
} else {
builder.emit(pass_kind::COLORIZE);
}
}
_ => unreachable!("unsupported filter type was encoded"),
}
builder.ensure_result_in_original();
}
}
pub(crate) fn steps(&self) -> impl Iterator<Item = &[FilterInstanceData]> {
self.steps.as_slice().iter().map(Vec::as_slice)
}
pub(crate) fn copy_pass(&self) -> Option<&[GpuCopyInstance]> {
(!self.copy_pass.is_empty()).then_some(&self.copy_pass)
}
fn clear(&mut self) {
self.steps.clear();
self.copy_pass.clear();
}
fn step_mut(&mut self, step: usize) -> &mut Vec<FilterInstanceData> {
if self.steps.len() <= step {
self.steps.resize_with(step + 1, Vec::new);
}
&mut self.steps[step]
}
}
#[derive(Debug)]
struct FilterPassBuilder<'a> {
op: FilterOp,
texture_size: SizeU16,
passes: &'a mut FilterPassPlan,
sizer: DecimationSizer,
current_is_original: bool,
step: usize,
}
impl<'a> FilterPassBuilder<'a> {
fn new(op: FilterOp, texture_size: SizeU16, passes: &'a mut FilterPassPlan) -> Self {
let sizer = DecimationSizer::new(
op.textures.original.rect.width(),
op.textures.original.rect.height(),
);
Self {
op,
texture_size,
passes,
sizer,
current_is_original: true,
step: 0,
}
}
fn apply_pass_dimensions(&mut self, kind: u32) -> (SizeU16, SizeU16) {
match kind {
pass_kind::DOWNSCALE => {
let (sw, sh) = self.sizer.current();
let (dw, dh) = self.sizer.downscale();
(SizeU16::from_wh(sw, sh), SizeU16::from_wh(dw, dh))
}
pass_kind::UPSCALE => {
let (sw, sh) = self.sizer.current();
let (dw, dh) = self.sizer.upscale();
(SizeU16::from_wh(sw, sh), SizeU16::from_wh(dw, dh))
}
_ => {
let (w, h) = self.sizer.current();
let size = SizeU16::from_wh(w, h);
(size, size)
}
}
}
fn emit(&mut self, kind: u32) {
let (source_size, dest_size) = self.apply_pass_dimensions(kind);
let original = self.op.textures.original;
let temporary = self.op.textures.temporary;
let (source_rect, dest_rect) = if self.current_is_original {
(original.rect, temporary.rect)
} else {
(temporary.rect, original.rect)
};
let dest_texture_size = self.texture_size;
let rect_origin = |rect: RectU16| pack_u16_pair(rect.x0, rect.y0);
let size = |size: SizeU16| pack_u16_pair(size.width(), size.height());
self.passes.step_mut(self.step).push(FilterInstanceData {
source_origin: rect_origin(source_rect),
source_size: size(source_size),
dest_origin: rect_origin(dest_rect),
dest_size: size(dest_size),
dest_texture_size: size(dest_texture_size),
filter_data_offset: self.op.filter_data_offset,
original_origin: rect_origin(original.rect),
original_size: pack_u16_pair(original.rect.width(), original.rect.height()),
filter_pass_kind: kind,
});
self.step += 1;
self.current_is_original = !self.current_is_original;
}
fn emit_blur_sequence(&mut self, n_decimations: usize) {
for _ in 0..n_decimations {
self.emit(pass_kind::DOWNSCALE);
}
self.emit(pass_kind::BLUR_H);
let mut final_pass = pass_kind::BLUR_V;
if n_decimations > 0 {
self.emit(pass_kind::BLUR_V);
for _ in 0..n_decimations - 1 {
self.emit(pass_kind::UPSCALE);
}
final_pass = pass_kind::UPSCALE;
}
self.emit(final_pass);
}
fn ensure_result_in_original(&mut self) {
if !self.current_is_original {
self.emit(pass_kind::COPY);
}
}
fn push_copy_to_scratch_pass(&mut self) {
let original = self.op.textures.original;
let dest_texture_size = self.texture_size;
let copy_instance = GpuCopyInstance {
dest_texture_origin: pack_u16_pair(original.rect.x0, original.rect.y0),
source_texture_origin: pack_u16_pair(original.rect.x0, original.rect.y0),
copy_rect_size: pack_u16_pair(original.rect.width(), original.rect.height()),
dest_texture_size: pack_u16_pair(dest_texture_size.width(), dest_texture_size.height()),
};
self.passes.copy_pass.push(copy_instance);
}
}
impl FilterContext {
pub(crate) fn clear(&mut self) {
self.filters.clear();
}
pub(crate) fn push(&mut self, filter_data: &FilterData) -> PreparedGpuFilter {
let data_offset = self.total_texels();
let prepared = PreparedFilter::new(&filter_data.filter, &filter_data.transform);
let data = GpuFilterData::from(&prepared);
self.filters.push(data);
PreparedGpuFilter { data_offset, data }
}
pub(crate) fn is_empty(&self) -> bool {
self.filters.is_empty()
}
#[expect(
clippy::cast_possible_truncation,
reason = "filter count won't exceed u32"
)]
pub(crate) fn total_texels(&self) -> u32 {
self.filters.len() as u32 * GpuFilterData::SIZE_TEXELS
}
pub(crate) fn serialize_to_buffer(&self, buffer: &mut [u8]) {
let src = bytemuck::cast_slice::<GpuFilterData, u8>(&self.filters);
debug_assert!(
buffer.len() >= src.len(),
"filter data buffer too small: {} < {}",
buffer.len(),
src.len()
);
buffer[..src.len()].copy_from_slice(src);
}
pub(crate) fn required_filter_data_height(
&self,
resource_texture_dimension_2d: u32,
) -> Option<u32> {
let required_texels = self.total_texels();
if required_texels == 0 {
return None;
}
let height = required_texels.div_ceil(resource_texture_dimension_2d);
assert!(
height <= resource_texture_dimension_2d,
"Filter texture height exceeds resource texture dimensions"
);
Some(height)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schedule::round::FilterTextureRegions;
use crate::target::{LayerTextureId, TextureParity, TextureRegion};
use vello_common::color::AlphaColor;
use vello_common::filter::gaussian_blur::{compute_gaussian_kernel, plan_decimated_blur};
fn region(parity: TextureParity) -> TextureRegion {
TextureRegion {
target: LayerTextureId::new(parity, 0),
rect: RectU16::new(0, 0, 32, 24),
}
}
fn filter_op(gpu_filter: GpuFilterData, filter_data_offset: u32) -> FilterOp {
FilterOp {
textures: FilterTextureRegions::new(
region(TextureParity::Odd),
region(TextureParity::Even),
),
filter_data_offset,
gpu_filter,
}
}
fn gpu_offset() -> GpuFilterData {
GpuOffset::from(&Offset::new(1.0, 2.0)).into()
}
fn gpu_flood() -> GpuFilterData {
GpuFlood::from(&Flood::new(AlphaColor::new([0.2, 0.4, 0.6, 0.8]))).into()
}
fn gpu_blur(std_deviation: f32) -> GpuFilterData {
GpuGaussianBlur::from(&GaussianBlur::new(std_deviation, EdgeMode::None)).into()
}
fn gpu_shadow() -> GpuFilterData {
GpuDropShadow::from(&DropShadow::new(
3.0,
-4.0,
8.0,
EdgeMode::None,
AlphaColor::new([0.0, 0.0, 0.0, 1.0]),
))
.into()
}
#[test]
#[should_panic(expected = "Filter texture height exceeds resource texture dimensions")]
fn filter_data_height_must_fit_resource_texture_limit() {
let context = FilterContext {
filters: alloc::vec![gpu_offset()],
};
let _ = context.required_filter_data_height(1);
}
fn step_layout(plan: &FilterPassPlan) -> Vec<Vec<(u32, u32)>> {
plan.steps()
.map(|step| {
step.iter()
.map(|instance| (instance.filter_data_offset, instance.filter_pass_kind))
.collect()
})
.collect()
}
#[test]
fn pass_batching() {
let mut plan = FilterPassPlan::default();
plan.init(
[
filter_op(gpu_offset(), 0),
filter_op(gpu_blur(8.0), 1),
filter_op(gpu_shadow(), 2),
],
SizeU16::new(64),
);
assert_eq!(
step_layout(&plan),
alloc::vec![
alloc::vec![
(0, pass_kind::OFFSET),
(1, pass_kind::DOWNSCALE),
(2, pass_kind::OFFSET),
],
alloc::vec![
(0, pass_kind::COPY),
(1, pass_kind::DOWNSCALE),
(2, pass_kind::DOWNSCALE),
],
alloc::vec![(1, pass_kind::BLUR_H), (2, pass_kind::DOWNSCALE)],
alloc::vec![(1, pass_kind::BLUR_V), (2, pass_kind::BLUR_H)],
alloc::vec![(1, pass_kind::UPSCALE), (2, pass_kind::BLUR_V)],
alloc::vec![(1, pass_kind::UPSCALE), (2, pass_kind::UPSCALE)],
alloc::vec![(2, pass_kind::UPSCALE)],
alloc::vec![(2, pass_kind::COMPOSITE_DROP_SHADOW)],
]
);
}
#[test]
fn plan_reinit() {
let mut plan = FilterPassPlan::default();
plan.init(
[filter_op(gpu_shadow(), 0), filter_op(gpu_blur(8.0), 1)],
SizeU16::new(64),
);
assert!(plan.copy_pass().is_some());
assert!(plan.steps().count() > 2);
plan.init([filter_op(gpu_offset(), 0)], SizeU16::new(64));
assert!(plan.copy_pass().is_none());
assert_eq!(
step_layout(&plan),
[
alloc::vec![(0, pass_kind::OFFSET)],
alloc::vec![(0, pass_kind::COPY)],
]
);
}
#[test]
fn single_pass_filters_finish_in_original() {
let mut plan = FilterPassPlan::default();
plan.init(
[filter_op(gpu_offset(), 0), filter_op(gpu_flood(), 1)],
SizeU16::new(64),
);
assert!(plan.copy_pass().is_none());
assert_eq!(
step_layout(&plan),
[
alloc::vec![(0, pass_kind::OFFSET), (1, pass_kind::FLOOD)],
alloc::vec![(0, pass_kind::COPY), (1, pass_kind::COPY)],
]
);
}
#[test]
fn test_offset_conversion() {
let offset = Offset::new(10.5, -20.3);
let gpu_offset = GpuOffset::from(&offset);
assert_eq!(gpu_offset.header & 0x1F, filter_type::OFFSET);
assert_eq!(gpu_offset.dx, 10.5);
assert_eq!(gpu_offset.dy, -20.3);
}
fn check_round_trip<T>(gpu: T, expected_type: u32)
where
T: Into<GpuFilterData> + Copy + PartialEq + core::fmt::Debug + Pod,
{
let erased: GpuFilterData = gpu.into();
assert_eq!(erased.filter_type(), expected_type);
assert_eq!(bytemuck::cast::<_, T>(erased), gpu);
}
#[test]
fn test_offset_round_trip() {
check_round_trip(GpuOffset::from(&Offset::new(1.0, 2.0)), filter_type::OFFSET);
}
#[test]
fn test_flood_round_trip() {
check_round_trip(
GpuFlood::from(&Flood::new(AlphaColor::new([0.2, 0.4, 0.6, 0.8]))),
filter_type::FLOOD,
);
}
#[test]
fn test_gaussian_blur_round_trip() {
check_round_trip(
GpuGaussianBlur::from(&GaussianBlur::new(2.0, EdgeMode::None)),
filter_type::GAUSSIAN_BLUR,
);
}
#[test]
fn test_drop_shadow_round_trip() {
check_round_trip(
GpuDropShadow::from(&DropShadow::new(
3.0,
-4.0,
1.5,
EdgeMode::Duplicate,
AlphaColor::new([0.0, 0.0, 0.0, 1.0]),
)),
filter_type::DROP_SHADOW,
);
}
fn check_linear_kernel(kernel: &[f32; MAX_KERNEL_SIZE], size: u8, expected_taps: u8) {
let lk = LinearKernel::new(kernel, size);
assert_eq!(lk.n_taps, expected_taps);
let sum = lk.center_weight + 2.0 * lk.weights.iter().take(lk.n_taps as usize).sum::<f32>();
assert!(
(sum - 1.0).abs() < 1e-5,
"weights must sum to 1.0, got {sum}"
);
}
#[test]
fn linear_kernel_size_1() {
let (_n_dec, kernel, size) = plan_decimated_blur(0.0);
assert_eq!(size, 1);
check_linear_kernel(&kernel, size, 0);
}
#[test]
fn linear_kernel_size_3() {
let (kernel, size) = compute_gaussian_kernel(0.1);
assert_eq!(size, 3);
check_linear_kernel(&kernel, size, 1);
}
#[test]
fn linear_kernel_size_7() {
let (kernel, size) = compute_gaussian_kernel(1.0);
assert_eq!(size, 7);
check_linear_kernel(&kernel, size, 2);
}
#[test]
fn linear_kernel_size_13() {
let (kernel, size) = compute_gaussian_kernel(2.0);
assert_eq!(size, 13);
check_linear_kernel(&kernel, size, 3);
}
}