use frust_engine::filters::blur::{
FILTER_ATLAS_PADDING, FILTER_SIZE_BYTES, FilterInstanceData, GpuFilterData, GpuGaussianBlur,
LinearKernel, MAX_KERNEL_SIZE, MAX_TAPS_PER_SIDE, blur_passes, edge_mode, filter_type,
pack_u16_pair,
};
use frust_engine::filters::drop_shadow::{GpuDropShadow, drop_shadow_passes};
use frust_engine::filters::{
FilterPassKind, LayerFilter, MAX_BLUR_SIGMA, SERVED_EDGE_MODE, ServedFilter, device_blur_sigma,
push_filter_layer, served_blur, served_drop_shadow, served_filter,
};
use frust_engine::gpu::pipelines::{
EnginePipeline, EngineShaders, INTERMEDIATE_FORMAT, warm_up_descs,
};
use frust_engine::gpu::shader_src::{FILTER, FILTER_NAME};
use frust_engine::gpu::targets::{INTERMEDIATE_USAGE, IntermediateTargets};
use frust_engine::renderer::{FilterPassPlan, FilterResources};
use frust_engine::schedule::{PageConfig, PageParity, Round, RoundOp, RoundTarget, Schedule};
use frust_engine::{EngineDraw, EngineError};
use frust_gpu::{DownlevelProfile, PipelineCache, ShaderLibrary, TierCaps};
use kurbo::Affine;
use peniko::color::{AlphaColor, Srgb};
use vello_common::color::palette::css::RED;
use vello_common::filter::drop_shadow::DropShadow;
use vello_common::filter::gaussian_blur::{
GaussianBlur, compute_gaussian_kernel, plan_decimated_blur,
};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_common::geometry::SizeU16;
use vello_common::paint::Paint;
use vello_common::peniko::BlendMode;
use vello_common::record::{CommandRecorder, LayerProps};
use vello_common::strip::Strip;
use vello_common::tile::Tile;
use wgpu::naga;
const VIEWPORT: (u16, u16) = (256, 192);
const SIGMAS: [f32; 3] = [2.0, 8.0, 32.0];
fn caps() -> TierCaps {
TierCaps::fake(DownlevelProfile::Full)
}
fn recorder() -> CommandRecorder<EngineDraw> {
CommandRecorder::new(VIEWPORT.0, VIEWPORT.1)
}
fn layer(opacity: f32) -> LayerProps {
LayerProps {
blend_mode: BlendMode::default(),
opacity,
mask: None,
clip_path: None,
}
}
fn strips(x: u16, y: u16, width: u16) -> Vec<Strip> {
vec![
Strip::new(x, y, 0, false),
Strip::sentinel(y, u32::from(width) * u32::from(Tile::HEIGHT)),
]
}
fn draw(recorder: &mut CommandRecorder<EngineDraw>, x: u16, y: u16, width: u16) {
let strips = strips(x, y, width);
let depth = u32::try_from(recorder.draws.len()).expect("a test records a handful of draws");
recorder.push_draw(
EngineDraw::new(Paint::from(RED), depth, 0..strips.len()),
&strips,
);
}
fn blurred_layer(sigma: f32, opacity: f32) -> CommandRecorder<EngineDraw> {
let mut recorder = recorder();
push_filter_layer(
&mut recorder,
layer(opacity),
LayerFilter::Blur { sigma },
Affine::IDENTITY,
)
.expect("a finite σ under the ceiling records");
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
recorder
}
fn drop_shadow_layer(
offset: (f32, f32),
sigma: f32,
color: AlphaColor<Srgb>,
opacity: f32,
) -> CommandRecorder<EngineDraw> {
let mut recorder = recorder();
push_filter_layer(
&mut recorder,
layer(opacity),
LayerFilter::DropShadow {
offset,
sigma,
color,
},
Affine::IDENTITY,
)
.expect("a finite offset and σ under the ceiling records");
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
recorder
}
fn shadow(offset: (f32, f32), sigma: f32, color: AlphaColor<Srgb>) -> DropShadow {
let filter = Filter::from_primitive(FilterPrimitive::DropShadowOnly {
dx: offset.0,
dy: offset.1,
std_deviation: sigma,
color,
edge_mode: EdgeMode::default(),
});
match vello_common::filter::PreparedFilter::new(&filter, &Affine::IDENTITY) {
vello_common::filter::PreparedFilter::DropShadow(shadow) => shadow,
other => panic!("a `DropShadowOnly` primitive must prepare as a drop shadow: {other:?}"),
}
}
fn schedule(recorder: &CommandRecorder<EngineDraw>) -> Vec<Round> {
Schedule::build(recorder, &caps(), &PageConfig::default())
.expect("a blurred layer under the surface schedules")
}
fn escalation(recorder: &CommandRecorder<EngineDraw>) -> String {
match Schedule::build(recorder, &caps(), &PageConfig::default()) {
Err(EngineError::SchedulerEscalation { reason }) => reason,
other => panic!("expected an escalation, got {other:?}"),
}
}
#[test]
fn every_sigma_plans_the_shared_kernel_rather_than_one_of_this_tiers_own() {
for sigma in SIGMAS {
let blur = GaussianBlur::new(sigma, EdgeMode::default());
let (n_decimations, kernel, kernel_size) = plan_decimated_blur(sigma);
assert_eq!(blur.n_decimations, n_decimations, "σ {sigma}");
assert_eq!(blur.kernel_size, kernel_size, "σ {sigma}");
assert_eq!(blur.kernel, kernel, "σ {sigma}");
assert!(
usize::from(kernel_size) <= MAX_KERNEL_SIZE,
"σ {sigma} planned a kernel of {kernel_size}, past the {MAX_KERNEL_SIZE} the \
decimation plan is supposed to bound it to"
);
}
}
#[test]
fn a_larger_sigma_costs_decimations_rather_than_a_wider_kernel() {
let decimations: Vec<usize> = SIGMAS
.iter()
.map(|sigma| plan_decimated_blur(*sigma).0)
.collect();
assert_eq!(decimations, vec![0, 2, 4], "σ 2 / 8 / 32");
}
#[test]
fn the_bilinear_kernel_splits_back_into_the_discrete_one() {
for sigma in SIGMAS {
let (kernel, kernel_size) = compute_gaussian_kernel(plan_remaining_sigma(sigma));
let linear = LinearKernel::new(&kernel, kernel_size);
let radius = usize::from(kernel_size) / 2;
assert_eq!(
linear.center_weight, kernel[radius],
"σ {sigma}: the centre tap is not merged with anything"
);
for tap in 0..usize::from(linear.n_taps) {
let merged = linear.weights[tap];
let offset = linear.offsets[tap];
let low = 2 * tap + 1;
let high_share = merged * (offset - low as f32);
let low_share = merged - high_share;
assert!(
(low_share - kernel[radius + low]).abs() < 1e-6,
"σ {sigma}, tap {tap}: low share {low_share} != {}",
kernel[radius + low]
);
let high = kernel.get(radius + low + 1).copied().unwrap_or(0.0);
assert!(
(high_share - high).abs() < 1e-6,
"σ {sigma}, tap {tap}: high share {high_share} != {high}"
);
}
}
}
#[test]
fn the_bilinear_kernel_still_sums_to_one() {
for sigma in SIGMAS {
let blur = GaussianBlur::new(sigma, EdgeMode::default());
let linear = LinearKernel::new(&blur.kernel, blur.kernel_size);
let sum: f32 = linear.center_weight
+ 2.0
* linear
.weights
.iter()
.take(usize::from(linear.n_taps))
.sum::<f32>();
assert!((sum - 1.0).abs() < 1e-5, "σ {sigma} summed to {sum}");
}
}
#[test]
fn no_kernel_the_plan_produces_needs_more_taps_than_the_shader_reads() {
for sigma in [0.0, 0.1, 0.5, 1.0, 2.0, 8.0, 32.0, 1024.0, MAX_BLUR_SIGMA] {
let blur = GaussianBlur::new(sigma, EdgeMode::default());
let linear = LinearKernel::new(&blur.kernel, blur.kernel_size);
assert!(
usize::from(linear.n_taps) <= MAX_TAPS_PER_SIDE,
"σ {sigma} merged into {} taps, past the {MAX_TAPS_PER_SIDE} the shader reads",
linear.n_taps
);
}
}
#[test]
fn a_zero_sigma_is_an_identity_kernel_rather_than_a_refusal() {
let blur = GaussianBlur::new(0.0, EdgeMode::default());
let linear = LinearKernel::new(&blur.kernel, blur.kernel_size);
assert_eq!(blur.kernel_size, 1);
assert_eq!(linear.n_taps, 0);
assert!((linear.center_weight - 1.0).abs() < 1e-6);
}
fn plan_remaining_sigma(sigma: f32) -> f32 {
let mut variance = sigma * sigma;
while variance > 4.0 {
variance = (variance - 1.5) * 0.25;
}
variance.sqrt()
}
#[test]
fn every_filter_parameter_block_is_one_size() {
assert_eq!(FILTER_SIZE_BYTES, 48);
assert_eq!(size_of::<GpuFilterData>(), FILTER_SIZE_BYTES);
assert_eq!(size_of::<GpuGaussianBlur>(), FILTER_SIZE_BYTES);
assert_eq!(size_of::<GpuDropShadow>(), FILTER_SIZE_BYTES);
assert_eq!(align_of::<GpuFilterData>(), 16);
assert_eq!(align_of::<GpuGaussianBlur>(), 16);
assert_eq!(align_of::<GpuDropShadow>(), 16);
assert_eq!(GpuFilterData::SIZE_TEXELS, 3);
}
#[test]
fn a_drop_shadows_parameter_block_round_trips_through_the_erased_form() {
for (offset, sigma) in [((0.0, 0.0), 2.0), ((3.0, -4.0), 8.0), ((-12.0, 20.0), 32.0)] {
let shadow_value = shadow(offset, sigma, RED);
let packed = GpuDropShadow::from(&shadow_value);
let erased = GpuFilterData::from(packed);
assert_eq!(
erased.filter_type(),
filter_type::DROP_SHADOW,
"{offset:?} σ {sigma}"
);
assert_eq!(
erased.n_decimations(),
shadow_value.n_decimations,
"{offset:?} σ {sigma}"
);
assert_eq!(
bytemuck::cast::<GpuFilterData, GpuDropShadow>(erased),
packed,
"{offset:?} σ {sigma}"
);
assert_eq!(packed.dx, offset.0, "{offset:?} σ {sigma}");
assert_eq!(packed.dy, offset.1, "{offset:?} σ {sigma}");
assert_eq!(
packed.color,
RED.premultiply().to_rgba8().to_u32(),
"{offset:?} σ {sigma}"
);
let linear = LinearKernel::new(&shadow_value.kernel, shadow_value.kernel_size);
assert_eq!(
packed.center_weight, linear.center_weight,
"{offset:?} σ {sigma}"
);
assert_eq!(
(packed.header >> 11) & 0x3,
u32::from(linear.n_taps),
"{offset:?} σ {sigma}"
);
}
}
#[test]
fn a_drop_shadows_header_never_sets_the_composite_bit() {
for sigma in [0.0, 2.0, 8.0, 32.0, MAX_BLUR_SIGMA] {
let packed = GpuDropShadow::from(&shadow((3.0, -4.0), sigma, RED));
assert_eq!(packed.header & (1 << 13), 0, "σ {sigma}");
}
}
#[test]
fn the_filter_padding_is_half_the_widest_kernel() {
assert_eq!(FILTER_ATLAS_PADDING, (MAX_KERNEL_SIZE / 2) as u16);
assert_eq!(FILTER_ATLAS_PADDING, 6);
}
#[test]
fn a_blurs_parameter_block_round_trips_through_the_erased_form() {
for sigma in SIGMAS {
let blur = GaussianBlur::new(sigma, EdgeMode::default());
let packed = GpuGaussianBlur::from(&blur);
let erased = GpuFilterData::from(packed);
assert_eq!(
erased.filter_type(),
filter_type::GAUSSIAN_BLUR,
"σ {sigma}"
);
assert_eq!(erased.n_decimations(), blur.n_decimations, "σ {sigma}");
assert_eq!(
bytemuck::cast::<GpuFilterData, GpuGaussianBlur>(erased),
packed,
"σ {sigma}"
);
let linear = LinearKernel::new(&blur.kernel, blur.kernel_size);
assert_eq!((packed.header >> 5) & 0x3, edge_mode::NONE, "σ {sigma}");
assert_eq!(
(packed.header >> 11) & 0x3,
u32::from(linear.n_taps),
"σ {sigma}"
);
}
}
#[test]
fn a_blurs_header_leaves_the_drop_shadows_bit_alone() {
for sigma in [0.0, 2.0, 8.0, 32.0, MAX_BLUR_SIGMA] {
let packed = GpuGaussianBlur::from(&GaussianBlur::new(sigma, EdgeMode::default()));
assert_eq!(packed.header & (1 << 13), 0, "σ {sigma}");
}
}
#[test]
fn the_filter_instance_matches_its_shader_layout() {
let module = validate(FILTER_NAME, FILTER);
assert_eq!(
struct_span(&module, "FilterInstanceData") as usize,
size_of::<FilterInstanceData>(),
"the shader's `FilterInstanceData` must stay byte-identical to the Rust one"
);
assert_eq!(size_of::<FilterInstanceData>(), 32);
}
#[test]
fn an_instance_carries_its_steps_extents_and_the_pages_it_addresses() {
let blur = GaussianBlur::new(8.0, EdgeMode::default());
let original = SizeU16::from_wh(96, 64);
let steps = blur_passes(&blur, original);
let first = steps.first().expect("a blur costs at least one pass");
let instance = FilterInstanceData::new(first, 3, (0, 0), (0, 0), SizeU16::new(512), original);
assert_eq!(instance.filter_data_offset, 3);
assert_eq!(instance.filter_pass_kind, first.kind.code());
assert_eq!(
instance.source_size,
pack_u16_pair(first.source.width(), first.source.height())
);
assert_eq!(
instance.dest_size,
pack_u16_pair(first.dest.width(), first.dest.height())
);
assert_eq!(instance.dest_texture_size, pack_u16_pair(512, 512));
assert_eq!(instance.original_size, pack_u16_pair(96, 64));
}
#[test]
fn the_pass_kinds_keep_the_reference_numbering() {
assert_eq!(FilterPassKind::Copy.code(), 0);
assert_eq!(FilterPassKind::Offset.code(), 2);
assert_eq!(FilterPassKind::Downscale.code(), 3);
assert_eq!(FilterPassKind::BlurH.code(), 4);
assert_eq!(FilterPassKind::BlurV.code(), 5);
assert_eq!(FilterPassKind::Upscale.code(), 6);
assert_eq!(FilterPassKind::Colorize.code(), 8);
}
#[test]
fn the_filter_module_parses_and_validates() {
validate(FILTER_NAME, FILTER);
}
#[test]
fn the_filter_module_is_its_preludes_followed_by_its_own_source() {
let helpers = include_str!("../shaders/helpers.wgsl");
let kernels = include_str!("../shaders/filters_blur.wgsl");
let shadow_kernels = include_str!("../shaders/filters_drop_shadow.wgsl");
let entry = include_str!("../shaders/filter.wgsl");
assert_eq!(FILTER, format!("{helpers}{kernels}{shadow_kernels}{entry}"));
for (name, source) in [
("helpers", helpers),
("filters_blur", kernels),
("filters_drop_shadow", shadow_kernels),
] {
let declaration = source
.lines()
.find(|line| !line.trim_start().starts_with("//") && line.contains("@group"));
assert!(
declaration.is_none(),
"{name} is prepended to every module that uses it, so a binding of its own would \
change their derived bind-group layouts: {declaration:?}"
);
}
}
#[test]
fn every_bilinear_sample_goes_through_the_region_bounded_helper() {
let kernels = include_str!("../shaders/filters_blur.wgsl");
let code: Vec<&str> = kernels
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect();
let samples = code
.iter()
.filter(|line| line.contains("textureSampleLevel("))
.count();
assert_eq!(
samples, 1,
"the blur kernels take exactly one bilinear sample, inside \
`sample_region_bilinear`; every other kernel calls that helper"
);
assert!(
kernels.contains("fn sample_region_bilinear("),
"the helper the count above is about has to be the one this module declares"
);
for (name, source) in [
("helpers", include_str!("../shaders/helpers.wgsl")),
(
"filters_drop_shadow",
include_str!("../shaders/filters_drop_shadow.wgsl"),
),
("filter", include_str!("../shaders/filter.wgsl")),
] {
assert!(
!source.contains("textureSampleLevel("),
"{name} samples bilinearly outside `sample_region_bilinear`"
);
}
}
#[test]
fn the_shader_constants_match_the_host_ones() {
let entry = include_str!("../shaders/filter.wgsl");
let kernels = include_str!("../shaders/filters_blur.wgsl");
assert_shader_const(
entry,
"FILTER_ATLAS_PADDING",
u32::from(FILTER_ATLAS_PADDING),
);
assert_shader_const(entry, "FILTER_SIZE_BYTES", FILTER_SIZE_BYTES as u32);
assert_shader_const(kernels, "MAX_TAPS_PER_SIDE", MAX_TAPS_PER_SIDE as u32);
for kind in [
FilterPassKind::Copy,
FilterPassKind::Offset,
FilterPassKind::Downscale,
FilterPassKind::BlurH,
FilterPassKind::BlurV,
FilterPassKind::Upscale,
FilterPassKind::Colorize,
] {
let name = match kind {
FilterPassKind::Copy => "PASS_COPY",
FilterPassKind::Offset => "PASS_OFFSET",
FilterPassKind::Downscale => "PASS_DOWNSCALE",
FilterPassKind::BlurH => "PASS_BLUR_H",
FilterPassKind::BlurV => "PASS_BLUR_V",
FilterPassKind::Upscale => "PASS_UPSCALE",
FilterPassKind::Colorize => "PASS_COLORIZE",
};
assert_shader_const(entry, name, kind.code());
}
}
fn assert_shader_const(source: &str, name: &str, expected: u32) {
let needle = format!("const {name}: u32 = ");
let line = source
.lines()
.find(|line| line.trim_start().starts_with(&needle))
.unwrap_or_else(|| panic!("the shader declares no `{name}`"));
let value = line
.trim_start()
.trim_start_matches(&needle)
.trim_end()
.trim_end_matches(';')
.trim_end_matches('u');
assert_eq!(
value.parse::<u32>().ok(),
Some(expected),
"the shader's `{name}` is `{value}`, the host's is `{expected}`"
);
}
fn validate(name: &str, src: &str) -> naga::Module {
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|err| panic!("{name} failed to parse: {err:?}"));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap_or_else(|err| panic!("{name} failed validation: {err:?}"));
module
}
fn struct_span(module: &naga::Module, name: &str) -> u32 {
module
.types
.iter()
.find_map(|(_, ty)| match (&ty.name, &ty.inner) {
(Some(ty_name), naga::TypeInner::Struct { span, .. }) if ty_name == name => Some(*span),
_ => None,
})
.unwrap_or_else(|| panic!("the module declares no struct named {name}"))
}
#[test]
fn a_blur_is_its_decimations_a_convolution_and_the_way_back() {
let expectations: [(f32, Vec<FilterPassKind>); 3] = [
(2.0, vec![FilterPassKind::BlurH, FilterPassKind::BlurV]),
(
8.0,
vec![
FilterPassKind::Downscale,
FilterPassKind::Downscale,
FilterPassKind::BlurH,
FilterPassKind::BlurV,
FilterPassKind::Upscale,
FilterPassKind::Upscale,
],
),
(
32.0,
vec![
FilterPassKind::Downscale,
FilterPassKind::Downscale,
FilterPassKind::Downscale,
FilterPassKind::Downscale,
FilterPassKind::BlurH,
FilterPassKind::BlurV,
FilterPassKind::Upscale,
FilterPassKind::Upscale,
FilterPassKind::Upscale,
FilterPassKind::Upscale,
],
),
];
for (sigma, expected) in expectations {
let blur = GaussianBlur::new(sigma, EdgeMode::default());
let kinds: Vec<FilterPassKind> = blur_passes(&blur, SizeU16::from_wh(96, 64))
.iter()
.map(|step| step.kind)
.collect();
assert_eq!(kinds, expected, "σ {sigma}");
}
}
#[test]
fn a_blurs_pass_sequence_is_always_even_and_never_needs_a_closing_copy() {
for sigma in [0.0, 0.5, 2.0, 8.0, 32.0, 512.0, MAX_BLUR_SIGMA] {
let blur = GaussianBlur::new(sigma, EdgeMode::default());
let steps = blur_passes(&blur, SizeU16::from_wh(96, 64));
assert_eq!(steps.len(), 2 * blur.n_decimations + 2, "σ {sigma}");
assert!(steps.len().is_multiple_of(2), "σ {sigma}");
assert!(
!steps.iter().any(|step| step.kind == FilterPassKind::Copy),
"σ {sigma} planned a closing copy it should not have needed"
);
}
}
#[test]
fn the_extents_halve_and_double_back_to_the_layers_own() {
let size = SizeU16::from_wh(97, 63);
let blur = GaussianBlur::new(8.0, EdgeMode::default());
let steps = blur_passes(&blur, size);
assert_eq!(
steps.first().map(|step| step.source),
Some(size),
"the first pass reads the layer at its own extent"
);
assert_eq!(
steps.last().map(|step| step.dest),
Some(size),
"the last pass writes the layer back at its own extent"
);
for pair in steps.windows(2) {
assert_eq!(
pair[0].dest, pair[1].source,
"a pass reads exactly what the pass before it wrote: {pair:?}"
);
}
let decimated = SizeU16::from_wh(25, 16);
for step in &steps {
if step.kind == FilterPassKind::BlurH || step.kind == FilterPassKind::BlurV {
assert_eq!(step.source, decimated);
assert_eq!(step.dest, decimated);
}
}
}
#[test]
fn only_the_rescaling_passes_change_extent() {
let blur = GaussianBlur::new(32.0, EdgeMode::default());
for step in blur_passes(&blur, SizeU16::from_wh(96, 64)) {
match step.kind {
FilterPassKind::Downscale => {
assert_eq!(step.dest.width(), step.source.width().div_ceil(2));
assert_eq!(step.dest.height(), step.source.height().div_ceil(2));
}
FilterPassKind::Upscale => {
assert!(step.dest.width() > step.source.width());
assert!(step.dest.height() > step.source.height());
}
FilterPassKind::BlurH | FilterPassKind::BlurV | FilterPassKind::Copy => {
assert_eq!(step.source, step.dest);
}
FilterPassKind::Offset | FilterPassKind::Colorize => {
unreachable!("a blur's own sequence never emits an offset or colourize pass")
}
}
}
}
#[test]
fn a_drop_shadow_is_the_offset_the_blur_and_the_colorize() {
for (offset, sigma) in [((0.0, 0.0), 2.0), ((3.0, -4.0), 8.0), ((12.0, 20.0), 32.0)] {
let shadow_value = shadow(offset, sigma, RED);
let blur = GaussianBlur::new(sigma, EdgeMode::default());
let size = SizeU16::from_wh(96, 64);
let steps = drop_shadow_passes(&shadow_value, size);
let (first, rest) = steps
.split_first()
.expect("a shadow costs at least one pass");
let (last, middle) = rest
.split_last()
.expect("a shadow costs at least two passes");
assert_eq!(first.kind, FilterPassKind::Offset, "{offset:?} σ {sigma}");
assert_eq!(first.source, size, "{offset:?} σ {sigma}");
assert_eq!(first.dest, size, "{offset:?} σ {sigma}");
assert_eq!(last.kind, FilterPassKind::Colorize, "{offset:?} σ {sigma}");
assert_eq!(last.source, size, "{offset:?} σ {sigma}");
assert_eq!(last.dest, size, "{offset:?} σ {sigma}");
let blur_kinds: Vec<FilterPassKind> = middle.iter().map(|step| step.kind).collect();
let expected_kinds: Vec<FilterPassKind> = blur_passes(&blur, size)
.iter()
.map(|step| step.kind)
.collect();
assert_eq!(blur_kinds, expected_kinds, "{offset:?} σ {sigma}");
}
}
#[test]
fn a_drop_shadows_pass_sequence_is_always_even_and_never_needs_a_closing_copy() {
for sigma in [0.0, 0.5, 2.0, 8.0, 32.0, 512.0, MAX_BLUR_SIGMA] {
let shadow_value = shadow((3.0, -4.0), sigma, RED);
let steps = drop_shadow_passes(&shadow_value, SizeU16::from_wh(96, 64));
assert_eq!(steps.len(), 2 * shadow_value.n_decimations + 4, "σ {sigma}");
assert!(steps.len().is_multiple_of(2), "σ {sigma}");
assert!(
!steps.iter().any(|step| step.kind == FilterPassKind::Copy),
"σ {sigma} planned a closing copy it should not have needed"
);
}
}
#[test]
fn a_blurred_layers_bounds_grow_by_the_blurs_own_spread() {
let plain = {
let mut recorder = recorder();
recorder.push_layer(layer(0.5), None);
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
recorder
};
let plain_bbox = plain.layers[0].bbox;
for sigma in SIGMAS {
let blurred = blurred_layer(sigma, 0.5);
let bbox = blurred.layers[0].bbox;
let spread = (3.0 * sigma).ceil() as u16;
assert!(
bbox.width() >= plain_bbox.width() + spread,
"σ {sigma}: {bbox:?} did not grow past {plain_bbox:?} by 3σ"
);
assert!(
bbox.height() >= plain_bbox.height() + spread,
"σ {sigma}: {bbox:?} did not grow past {plain_bbox:?} by 3σ"
);
}
}
#[test]
fn a_sigma_that_is_not_a_standard_deviation_is_refused_at_the_recorder() {
for sigma in [f32::NAN, f32::INFINITY, -1.0, MAX_BLUR_SIGMA + 1.0] {
let mut recorder = recorder();
let refusal = push_filter_layer(
&mut recorder,
layer(0.5),
LayerFilter::Blur { sigma },
Affine::IDENTITY,
);
assert!(
matches!(refusal, Err(EngineError::InvalidGeometry)),
"σ {sigma} recorded as {refusal:?}"
);
assert!(
recorder.layers.is_empty(),
"σ {sigma} opened a layer despite being refused"
);
}
}
#[test]
fn a_non_finite_transform_is_refused_at_the_recorder() {
let mut recorder = recorder();
let refusal = push_filter_layer(
&mut recorder,
layer(0.5),
LayerFilter::Blur { sigma: 8.0 },
Affine::translate((f64::NAN, 0.0)),
);
assert!(matches!(refusal, Err(EngineError::InvalidTransform)));
assert!(recorder.layers.is_empty());
}
#[test]
fn the_transform_scales_the_blur_into_device_space() {
let mut scaled = recorder();
push_filter_layer(
&mut scaled,
layer(0.5),
LayerFilter::Blur { sigma: 8.0 },
Affine::scale(2.0),
)
.expect("a finite transform records");
draw(&mut scaled, 64, 64, 32);
scaled.pop_layer();
let unscaled = blurred_layer(8.0, 0.5);
assert!(
scaled.layers[0].bbox.width() > unscaled.layers[0].bbox.width(),
"a 2x transform must widen the blurred layer's bounds"
);
}
#[test]
fn a_shadowed_layers_bounds_grow_by_the_blurs_spread_and_the_shadows_own_offset() {
let plain = {
let mut recorder = recorder();
recorder.push_layer(layer(0.5), None);
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
recorder
};
let plain_bbox = plain.layers[0].bbox;
let unshifted = drop_shadow_layer((0.0, 0.0), 8.0, RED, 0.5);
let shifted = drop_shadow_layer((40.0, 0.0), 8.0, RED, 0.5);
assert!(
unshifted.layers[0].bbox.width() >= plain_bbox.width(),
"a shadow's blur alone must still grow the layer: {:?}",
unshifted.layers[0].bbox
);
assert!(
shifted.layers[0].bbox.width() > unshifted.layers[0].bbox.width(),
"an offset shadow must grow its layer past an unshifted one of the same σ: {:?} vs {:?}",
shifted.layers[0].bbox,
unshifted.layers[0].bbox
);
}
#[test]
fn a_drop_shadow_with_a_non_finite_sigma_or_offset_is_refused_at_the_recorder() {
let cases: [(f32, f32, f32); 5] = [
(f32::NAN, 0.0, 0.0),
(8.0, f32::NAN, 0.0),
(8.0, 0.0, f32::NAN),
(8.0, f32::INFINITY, 0.0),
(MAX_BLUR_SIGMA + 1.0, 0.0, 0.0),
];
for (sigma, dx, dy) in cases {
let mut recorder = recorder();
let refusal = push_filter_layer(
&mut recorder,
layer(0.5),
LayerFilter::DropShadow {
offset: (dx, dy),
sigma,
color: RED,
},
Affine::IDENTITY,
);
assert!(
matches!(refusal, Err(EngineError::InvalidGeometry)),
"σ {sigma}, offset ({dx}, {dy}) recorded as {refusal:?}"
);
assert!(
recorder.layers.is_empty(),
"σ {sigma}, offset ({dx}, {dy}) opened a layer despite being refused"
);
}
}
#[test]
fn served_drop_shadow_reads_back_what_was_recorded() {
let recording = drop_shadow_layer((3.0, -4.0), 8.0, RED, 0.5);
let kind = &recording.layers[0].kind;
let served = served_drop_shadow(0, kind).expect("a recorded shadow-only drop shadow serves");
assert_eq!(served.dx, 3.0);
assert_eq!(served.dy, -4.0);
assert_eq!(served.color, RED);
assert!(
!served.composite_original,
"this engine never serves the compositing shape"
);
}
#[test]
fn the_frame_paths_own_entry_point_plans_a_blur() {
let rounds = Schedule::build(&blurred_layer(8.0, 0.5), &caps(), &PageConfig::default())
.expect("the one entry point plans a blur");
assert!(rounds.iter().any(|round| round.filter_pass().is_some()));
}
#[test]
fn a_blurred_layer_costs_its_contents_its_passes_and_the_composite() {
let blur = GaussianBlur::new(8.0, EdgeMode::default());
let passes = blur_passes(&blur, SizeU16::from_wh(1, 1)).len();
let rounds = schedule(&blurred_layer(8.0, 0.5));
assert_eq!(
rounds.len(),
passes + 2,
"one round for the contents, one per pass, and the surface's own: {rounds:#?}"
);
let contents = &rounds[0];
assert!(contents.filter_pass().is_none());
assert_eq!(contents.draw_count(), 1);
let contents_page = contents.page().expect("the contents render into a page");
assert!(!contents_page.continued, "the layer's first round clears");
let mut source = contents_page.parity;
for round in &rounds[1..rounds.len() - 1] {
let pass = round.filter_pass().expect("a filter round runs a pass");
let page = round.page().expect("a filter pass writes a page");
assert!(round.ops.is_empty(), "a filter round draws nothing");
assert_eq!(pass.layer, 0);
assert_eq!(pass.source, source, "a pass reads the page before it wrote");
assert_eq!(
page.parity,
source.opposite(),
"a pass writes the group it did not read"
);
assert!(
!page.continued,
"a filter round clears, so what surrounds the region it writes is transparent"
);
assert_eq!(page.size, contents_page.size, "both pages are one size");
assert_eq!(page.bounds, contents_page.bounds);
source = page.parity;
}
let root = rounds.last().expect("a frame always has a root round");
assert!(matches!(root.target, RoundTarget::Root));
let composite = root
.composites()
.next()
.expect("the surface composites the filtered layer");
assert_eq!(
composite.parity, contents_page.parity,
"an even sequence leaves the result in the page the contents were rendered into"
);
assert_eq!(composite.opacity, 0.5);
}
#[test]
fn the_scratch_page_goes_back_at_the_last_pass_and_the_result_at_the_composite() {
let rounds = schedule(&blurred_layer(8.0, 0.5));
let result = rounds[0]
.page()
.expect("the contents render into a page")
.parity;
let filter_rounds: Vec<&Round> = rounds
.iter()
.filter(|round| round.filter_pass().is_some())
.collect();
let (last, earlier) = filter_rounds
.split_last()
.expect("a blur costs at least one pass");
for round in earlier {
assert!(
round.released.is_empty(),
"both pages are live until the last pass: {round:#?}"
);
}
assert_eq!(
last.released,
vec![result.opposite()],
"the last pass hands the scratch group back"
);
let root = rounds.last().expect("a frame always has a root round");
assert_eq!(
root.released,
vec![result],
"the composite hands the result's group back"
);
}
#[test]
fn a_blurred_layer_at_full_opacity_is_still_isolated() {
let rounds = schedule(&blurred_layer(8.0, 1.0));
assert!(rounds.len() > 1, "an opaque blurred layer was inlined away");
assert!(rounds.iter().any(|round| round.filter_pass().is_some()));
}
#[test]
fn a_blurred_layer_that_contributes_nothing_costs_no_pass() {
let transparent = schedule(&blurred_layer(8.0, 0.0));
assert_eq!(transparent.len(), 1, "{transparent:#?}");
assert!(
transparent
.iter()
.all(|round| round.filter_pass().is_none())
);
let mut empty = recorder();
push_filter_layer(
&mut empty,
layer(0.5),
LayerFilter::Blur { sigma: 8.0 },
Affine::IDENTITY,
)
.expect("a finite σ records");
empty.pop_layer();
let rounds = schedule(&empty);
assert_eq!(rounds.len(), 1, "{rounds:#?}");
assert!(rounds.iter().all(|round| round.filter_pass().is_none()));
}
#[test]
fn a_larger_sigma_costs_more_rounds() {
let counts: Vec<usize> = SIGMAS
.iter()
.map(|sigma| schedule(&blurred_layer(*sigma, 0.5)).len())
.collect();
assert_eq!(counts, vec![4, 8, 12], "σ 2 / 8 / 32");
}
#[test]
fn a_filter_layer_takes_its_depths_group_for_its_contents() {
let rounds = schedule(&blurred_layer(2.0, 0.5));
let contents = rounds[0].page().expect("the contents render into a page");
assert_eq!(contents.depth, 1);
assert_eq!(contents.parity, PageParity::from_depth(1));
}
#[test]
fn a_blurred_layer_beside_an_opacity_layer_is_served_by_cutting_the_surfaces_round() {
let mut recorder = recorder();
recorder.push_layer(layer(0.5), None);
draw(&mut recorder, 16, 16, 16);
recorder.pop_layer();
push_filter_layer(
&mut recorder,
layer(0.5),
LayerFilter::Blur { sigma: 2.0 },
Affine::IDENTITY,
)
.expect("a finite σ records");
draw(&mut recorder, 96, 96, 16);
recorder.pop_layer();
let rounds = schedule(&recorder);
let filter_rounds = rounds
.iter()
.filter(|round| round.filter_pass().is_some())
.count();
assert_eq!(filter_rounds, 2, "{rounds:#?}");
assert!(
rounds
.iter()
.filter(|round| matches!(round.target, RoundTarget::Root))
.count()
> 1,
"the surface's round had to be cut to hand the second group back: {rounds:#?}"
);
assert_eq!(
rounds
.iter()
.flat_map(|round| round.ops.iter())
.filter(|op| matches!(op, RoundOp::Composite(_)))
.count(),
2
);
}
#[test]
fn a_shadowed_layer_costs_its_contents_its_passes_and_the_composite() {
let shadow_value = shadow((3.0, -4.0), 8.0, RED);
let passes = drop_shadow_passes(&shadow_value, SizeU16::from_wh(1, 1)).len();
let rounds = schedule(&drop_shadow_layer((3.0, -4.0), 8.0, RED, 0.5));
assert_eq!(
rounds.len(),
passes + 2,
"one round for the contents, one per pass, and the surface's own: {rounds:#?}"
);
let contents = &rounds[0];
assert!(contents.filter_pass().is_none());
let contents_page = contents.page().expect("the contents render into a page");
let mut source = contents_page.parity;
for round in &rounds[1..rounds.len() - 1] {
let pass = round.filter_pass().expect("a filter round runs a pass");
let page = round.page().expect("a filter pass writes a page");
assert_eq!(pass.source, source, "a pass reads the page before it wrote");
assert_eq!(
page.parity,
source.opposite(),
"a pass writes the group it did not read"
);
source = page.parity;
}
let root = rounds.last().expect("a frame always has a root round");
let composite = root
.composites()
.next()
.expect("the surface composites the shadowed layer");
assert_eq!(
composite.parity, contents_page.parity,
"an even sequence leaves the result in the page the contents were rendered into"
);
}
#[test]
fn every_filter_but_the_blur_or_drop_shadow_is_still_refused_by_name() {
let mut recorder = recorder();
let flood = Filter::from_primitive(FilterPrimitive::Flood { color: RED });
recorder.push_layer(
layer(0.5),
Some(vello_common::filter::FilterData::new(
flood,
Affine::IDENTITY,
)),
);
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
let reason = escalation(&recorder);
assert!(
reason.contains("Gaussian blur"),
"the reason has to say what the engine does serve: {reason}"
);
}
#[test]
fn a_drop_shadow_that_composites_the_original_is_refused_by_name() {
let mut recorder = recorder();
let compositing = Filter::from_primitive(FilterPrimitive::DropShadow {
dx: 3.0,
dy: -4.0,
std_deviation: 8.0,
color: RED,
edge_mode: EdgeMode::default(),
});
recorder.push_layer(
layer(0.5),
Some(vello_common::filter::FilterData::new(
compositing,
Affine::IDENTITY,
)),
);
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
let reason = escalation(&recorder);
assert!(
reason.contains("shadow-only"),
"the reason has to name what this engine actually serves: {reason}"
);
}
#[test]
fn the_shared_kind_dispatch_names_the_filter_the_recording_carries() {
let blurred = blurred_layer(8.0, 0.5);
assert!(
matches!(
served_filter(0, &blurred.layers[0].kind),
Ok(ServedFilter::Blur(_))
),
"a recorded Gaussian blur is dispatched as one"
);
let shadowed = drop_shadow_layer((3.0, -4.0), 8.0, RED, 0.5);
assert!(
matches!(
served_filter(0, &shadowed.layers[0].kind),
Ok(ServedFilter::DropShadow(_))
),
"a recorded shadow-only drop shadow is dispatched as one"
);
let mut plain = recorder();
plain.push_layer(layer(0.5), None);
draw(&mut plain, 64, 64, 32);
plain.pop_layer();
assert!(served_filter(0, &plain.layers[0].kind).is_err());
}
#[test]
fn a_served_filters_own_fault_is_what_the_refusal_names() {
let mut recorder = recorder();
let hostile = Filter::from_primitive(FilterPrimitive::GaussianBlur {
std_deviation: MAX_BLUR_SIGMA * 2.0,
edge_mode: EdgeMode::None,
});
recorder.push_layer(
layer(0.5),
Some(vello_common::filter::FilterData::new(
hostile,
Affine::IDENTITY,
)),
);
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
let reason = escalation(&recorder);
assert!(
reason.contains('σ') && reason.contains("Gaussian blur"),
"the reason has to name the blur's own σ, not the drop shadow it also is not: {reason}"
);
}
#[test]
fn an_edge_mode_the_kernels_do_not_implement_is_refused_by_name() {
for mode in [EdgeMode::Duplicate, EdgeMode::Wrap, EdgeMode::Mirror] {
for (what, filter) in [
(
"blur",
Filter::from_primitive(FilterPrimitive::GaussianBlur {
std_deviation: 8.0,
edge_mode: mode,
}),
),
(
"drop shadow",
Filter::from_primitive(FilterPrimitive::DropShadowOnly {
dx: 3.0,
dy: -4.0,
std_deviation: 8.0,
color: RED,
edge_mode: mode,
}),
),
] {
let mut recorder = recorder();
recorder.push_layer(
layer(0.5),
Some(vello_common::filter::FilterData::new(
filter,
Affine::IDENTITY,
)),
);
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
let reason = escalation(&recorder);
assert!(
reason.contains(&format!("{mode:?}")),
"a {what} with edge mode {mode:?} has to be refused by that mode's own name: \
{reason}"
);
}
}
assert_eq!(
SERVED_EDGE_MODE,
EdgeMode::None,
"the kernels read transparent black outside the region, which is `EdgeMode::None`"
);
assert!(served_filter(0, &blurred_layer(8.0, 0.5).layers[0].kind).is_ok());
}
fn recorded_filter(filter: Filter, transform: Affine) -> CommandRecorder<EngineDraw> {
let mut filter_data = vello_common::filter::FilterData::new(filter, Affine::IDENTITY);
filter_data.transform = transform;
let mut recorder = recorder();
recorder.push_layer(layer(0.5), Some(filter_data));
recorder.pop_layer();
recorder
}
fn recorded_blur(sigma: f32, transform: Affine) -> CommandRecorder<EngineDraw> {
recorded_filter(
Filter::from_primitive(FilterPrimitive::GaussianBlur {
std_deviation: sigma,
edge_mode: SERVED_EDGE_MODE,
}),
transform,
)
}
#[test]
fn the_device_space_sigma_is_the_one_the_reference_prepares_with() {
for (sigma, transform) in [
(8.0_f32, Affine::IDENTITY),
(8.0, Affine::scale(2.0)),
(8.0, Affine::scale(0.25)),
(32.0, Affine::scale_non_uniform(3.0, 1.0)),
(2.0, Affine::rotate(0.7) * Affine::scale(5.0)),
(8.0, Affine::translate((40.0, -12.0))),
] {
let recording = recorded_blur(sigma, transform);
let served = served_blur(0, &recording.layers[0].kind)
.expect("a σ and a transform inside the ceiling serve");
assert_eq!(
served.std_deviation,
device_blur_sigma(sigma, transform),
"σ {sigma} under {transform:?}: the engine's own device-space σ must be the one \
`PreparedFilter::new` prepared with"
);
}
}
#[test]
fn a_non_finite_transform_coefficient_is_refused_before_the_planner_sees_it() {
for coeffs in [
[f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0],
[1.0, f64::INFINITY, 0.0, 1.0, 0.0, 0.0],
[1.0, 0.0, f64::NEG_INFINITY, 1.0, 0.0, 0.0],
[1.0, 0.0, 0.0, f64::NAN, 0.0, 0.0],
] {
let transform = Affine::new(coeffs);
let recording = recorded_blur(8.0, transform);
let refusal = served_filter(0, &recording.layers[0].kind)
.expect_err("a non-finite transform coefficient is refused");
assert!(
refusal.contains("non-finite transform"),
"the reason has to name the transform: {refusal}"
);
assert!(escalation(&recording).contains("non-finite transform"));
}
}
#[test]
fn a_finite_transform_that_scales_sigma_past_the_ceiling_is_refused() {
let transform = Affine::scale(1.0e6);
let recording = recorded_blur(8.0, transform);
let refusal = served_filter(0, &recording.layers[0].kind)
.expect_err("a device-space σ past the ceiling is refused");
assert!(
refusal.contains("device-space σ"),
"the reason has to name the scaled σ rather than the recorded one: {refusal}"
);
assert!(escalation(&recording).contains("device-space σ"));
let mut recorder = recorder();
assert!(matches!(
push_filter_layer(
&mut recorder,
layer(0.5),
LayerFilter::Blur { sigma: 8.0 },
transform,
),
Err(EngineError::InvalidGeometry)
));
assert!(recorder.layers.is_empty());
}
#[test]
fn the_device_space_sigma_bound_is_inclusive_and_exact_at_its_boundary() {
let transform = Affine::scale(2.0);
let at_boundary = MAX_BLUR_SIGMA / 2.0;
assert_eq!(
device_blur_sigma(at_boundary, transform),
MAX_BLUR_SIGMA,
"the case has to sit on the boundary exactly, not near it"
);
let served = served_blur(0, &recorded_blur(at_boundary, transform).layers[0].kind)
.expect("a device-space σ of exactly the ceiling is served");
assert_eq!(served.std_deviation, MAX_BLUR_SIGMA);
let past = f32::from_bits(at_boundary.to_bits() + 1);
assert!(
device_blur_sigma(past, transform) > MAX_BLUR_SIGMA,
"the next representable σ has to cross the boundary"
);
assert!(
served_filter(0, &recorded_blur(past, transform).layers[0].kind).is_err(),
"one ulp past the boundary is refused"
);
assert!(
served_filter(
0,
&recorded_blur(MAX_BLUR_SIGMA, Affine::IDENTITY).layers[0].kind
)
.is_ok()
);
}
#[test]
fn a_drop_shadows_sigma_is_bounded_in_device_space_too() {
let hostile = |transform| {
recorded_filter(
Filter::from_primitive(FilterPrimitive::DropShadowOnly {
dx: 3.0,
dy: -4.0,
std_deviation: 8.0,
color: RED,
edge_mode: SERVED_EDGE_MODE,
}),
transform,
)
};
assert!(served_filter(0, &hostile(Affine::IDENTITY).layers[0].kind).is_ok());
assert!(served_filter(0, &hostile(Affine::scale(1.0e6)).layers[0].kind).is_err());
assert!(
served_filter(
0,
&hostile(Affine::new([f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0])).layers[0].kind
)
.is_err()
);
}
#[test]
fn a_filter_layer_inside_another_layer_is_refused() {
for outer in [0.5, 1.0] {
let mut recorder = recorder();
recorder.push_layer(layer(outer), None);
push_filter_layer(
&mut recorder,
layer(0.5),
LayerFilter::Blur { sigma: 8.0 },
Affine::IDENTITY,
)
.expect("a finite σ records");
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
recorder.pop_layer();
let reason = escalation(&recorder);
assert!(
reason.contains("recorded inside another layer"),
"outer opacity {outer}: {reason}"
);
}
}
#[test]
fn a_blur_that_grows_its_layer_past_a_page_is_refused_rather_than_clipped() {
let config = PageConfig {
min_page_size: 512,
max_page_size: 1024,
};
let mut recorder = recorder();
push_filter_layer(
&mut recorder,
layer(0.5),
LayerFilter::Blur { sigma: 400.0 },
Affine::IDENTITY,
)
.expect("a finite σ under the ceiling records");
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
assert!(
recorder.layers[0].bbox.width() > 1024,
"the blur has to be what pushes the layer past the ceiling: {:?}",
recorder.layers[0].bbox
);
assert!(matches!(
Schedule::build(&recorder, &caps(), &config),
Err(EngineError::IntermediateTextureLimitReached)
));
}
#[test]
fn no_sigma_the_recorder_accepts_panics_the_scheduler() {
let config = PageConfig {
min_page_size: 512,
max_page_size: 1024,
};
for sigma in [0.0, 0.5, 2.0, 8.0, 32.0, 400.0, 4000.0, MAX_BLUR_SIGMA] {
let mut recorder = recorder();
push_filter_layer(
&mut recorder,
layer(0.5),
LayerFilter::Blur { sigma },
Affine::IDENTITY,
)
.expect("every σ here is finite and inside the ceiling");
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
match Schedule::build(&recorder, &caps(), &config) {
Ok(rounds) => assert!(!rounds.is_empty(), "σ {sigma}"),
Err(
EngineError::IntermediateTextureTooLarge
| EngineError::IntermediateTextureLimitReached,
) => {}
Err(other) => panic!("σ {sigma} refused unexpectedly: {other:?}"),
}
}
}
#[test]
fn a_blurred_layer_with_a_blend_mode_is_refused_on_the_blend() {
use vello_common::peniko::{Compose, Mix};
let mut recorder = recorder();
let props = LayerProps {
blend_mode: BlendMode::new(Mix::Multiply, Compose::SrcOver),
opacity: 0.5,
mask: None,
clip_path: None,
};
push_filter_layer(
&mut recorder,
props,
LayerFilter::Blur { sigma: 8.0 },
Affine::IDENTITY,
)
.expect("a finite σ records");
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
let reason = escalation(&recorder);
assert!(reason.contains("blend mode"), "{reason}");
}
const FILTER_PAGE: u32 = 512;
const FILTER_REGION: (u32, u32) = (448, 384);
const FILTER_RECT: (u32, u32, u32, u32) = (160, 160, 128, 64);
const FILTER_RECT_AT_ORIGIN: (u32, u32, u32, u32) = (0, 0, 128, 64);
const PAGE_TEXEL_BYTES: u32 = 4;
static RENDER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn render_lock() -> std::sync::MutexGuard<'static, ()> {
RENDER_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::task::{Context, Poll, Waker};
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(value) => return value,
Poll::Pending => std::thread::yield_now(),
}
}
}
fn drain_error_scope(device: &wgpu::Device, scope: wgpu::ErrorScopeGuard) -> Option<wgpu::Error> {
use std::task::{Context, Poll, Waker};
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = std::pin::pin!(scope.pop());
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(error) => return error,
Poll::Pending => {
let _ = device.poll(wgpu::PollType::wait_indefinitely());
}
}
}
}
fn gpu() -> (wgpu::Device, wgpu::Queue, TierCaps) {
block_on(async {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
.await
.expect("no compatible GPU adapter");
println!("frust-engine filter adapter: {:?}", adapter.get_info());
let caps = TierCaps::probe(&adapter);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("frust-engine filter test device"),
required_features: wgpu::Features::empty(),
required_limits: frust_gpu::test_device_limits(&adapter, &caps),
..Default::default()
})
.await
.expect("failed to create the device");
(device, queue, caps)
})
}
struct FilterHarness {
device: wgpu::Device,
queue: wgpu::Queue,
caps: TierCaps,
pipeline: wgpu::RenderPipeline,
filters: FilterResources,
pages: [Page; 2],
_cache: PipelineCache,
}
struct Page {
texture: wgpu::Texture,
view: wgpu::TextureView,
}
impl FilterHarness {
fn new() -> Self {
let (device, queue, caps) = gpu();
let mut library = ShaderLibrary::new();
let shaders = EngineShaders::register(&mut library, &device);
let mut cache = PipelineCache::new(std::sync::Arc::new(library), None);
let desc = EnginePipeline::Filter.desc(&shaders, wgpu::TextureFormat::Bgra8Unorm);
assert!(
warm_up_descs(&shaders, wgpu::TextureFormat::Bgra8Unorm).contains(&desc),
"the filter description must be one the warm-up list already covers"
);
let pipeline = cache.get_or_create(&device, &desc).clone();
let pages = [page(&device, "a"), page(&device, "b")];
let filters = FilterResources::new(&device);
Self {
device,
queue,
caps,
pipeline,
filters,
pages,
_cache: cache,
}
}
fn upload_source(&self, image: &Image) {
self.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &self.pages[0].texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&image.to_rgba8(),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(image.width as u32 * PAGE_TEXEL_BYTES),
rows_per_image: Some(image.height as u32),
},
wgpu::Extent3d {
width: image.width as u32,
height: image.height as u32,
depth_or_array_layers: 1,
},
);
}
fn run(&mut self, blur: &GaussianBlur, steps: &[frust_engine::filters::FilterStep]) -> usize {
self.run_block(GpuFilterData::from(GpuGaussianBlur::from(blur)), steps)
}
fn run_block(
&mut self,
block: GpuFilterData,
steps: &[frust_engine::filters::FilterStep],
) -> usize {
let passes = u32::try_from(steps.len()).expect("a filter costs a handful of passes");
self.filters
.prepare(&self.device, &self.queue, &self.pipeline, &[block], passes);
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("frust-engine filter test"),
});
let mut source = 0_usize;
for (index, step) in steps.iter().enumerate() {
let dest = 1 - source;
let instance = u32::try_from(index).expect("a blur costs a handful of passes");
self.filters.write_instance(
&self.queue,
instance,
&FilterInstanceData::new(
step,
0,
(0, 0),
(0, 0),
SizeU16::from_wh(FILTER_PAGE as u16, FILTER_PAGE as u16),
SizeU16::from_wh(FILTER_REGION.0 as u16, FILTER_REGION.1 as u16),
),
);
self.filters.record_pass(
&self.device,
&mut encoder,
&FilterPassPlan {
label: "frust-engine filter test pass",
pipeline: &self.pipeline,
dest: &self.pages[dest].view,
source: &self.pages[source].view,
instance,
},
);
source = dest;
}
self.queue.submit([encoder.finish()]);
source
}
fn read_region(&self, page: usize) -> Image {
let bytes_per_row = (FILTER_PAGE * PAGE_TEXEL_BYTES).next_multiple_of(256);
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("frust-engine filter readback"),
size: u64::from(bytes_per_row) * u64::from(FILTER_PAGE),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("frust-engine filter readback copy"),
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &self.pages[page].texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: Some(FILTER_PAGE),
},
},
wgpu::Extent3d {
width: FILTER_PAGE,
height: FILTER_PAGE,
depth_or_array_layers: 1,
},
);
self.queue.submit([encoder.finish()]);
let slice = buffer.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |result| {
let _ = tx.send(result);
});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("the readback poll must succeed");
rx.recv()
.expect("the readback channel must stay open")
.expect("the readback buffer must map");
let mapped = slice
.get_mapped_range()
.expect("the mapped readback range must be readable");
let stride = bytes_per_row as usize;
let mut image = Image::new(FILTER_REGION.0 as usize, FILTER_REGION.1 as usize);
for y in 0..image.height {
for x in 0..image.width {
let at = y * stride + x * PAGE_TEXEL_BYTES as usize;
image.data[y * image.width + x] = [
f32::from(mapped[at]),
f32::from(mapped[at + 1]),
f32::from(mapped[at + 2]),
f32::from(mapped[at + 3]),
];
}
}
drop(mapped);
buffer.unmap();
image
}
}
fn page(device: &wgpu::Device, label: &str) -> Page {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: FILTER_PAGE,
height: FILTER_PAGE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE_FORMAT,
usage: INTERMEDIATE_USAGE | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
Page { texture, view }
}
#[derive(Clone)]
struct Image {
width: usize,
height: usize,
data: Vec<[f32; 4]>,
}
impl Image {
fn new(width: usize, height: usize) -> Self {
Self {
width,
height,
data: vec![[0.0; 4]; width * height],
}
}
fn with_rect(width: usize, height: usize, rect: (u32, u32, u32, u32), color: [f32; 4]) -> Self {
let mut image = Self::new(width, height);
for y in rect.1 as usize..(rect.1 + rect.3) as usize {
for x in rect.0 as usize..(rect.0 + rect.2) as usize {
image.data[y * width + x] = color;
}
}
image
}
fn sample(&self, x: i64, y: i64) -> [f32; 4] {
if x < 0 || y < 0 || x >= self.width as i64 || y >= self.height as i64 {
return [0.0; 4];
}
self.data[y as usize * self.width + x as usize]
}
fn to_rgba8(&self) -> Vec<u8> {
self.data
.iter()
.flat_map(|texel| texel.map(|c| c.clamp(0.0, 255.0).round() as u8))
.collect()
}
fn quantize(mut self) -> Self {
for texel in &mut self.data {
for channel in texel {
*channel = channel.clamp(0.0, 255.0).round();
}
}
self
}
fn worst_diff(&self, other: &Self, margin: usize) -> (f32, usize, usize) {
let mut worst = (0.0_f32, 0_usize, 0_usize);
for y in margin..self.height.saturating_sub(margin) {
for x in margin..self.width.saturating_sub(margin) {
let mine = self.data[y * self.width + x];
let theirs = other.data[y * other.width + x];
for channel in 0..4 {
let diff = (mine[channel] - theirs[channel]).abs();
if diff > worst.0 {
worst = (diff, x, y);
}
}
}
}
worst
}
fn mean_diff(&self, other: &Self, margin: usize) -> f32 {
let mut total = 0.0_f64;
let mut count = 0_u64;
for y in margin..self.height.saturating_sub(margin) {
for x in margin..self.width.saturating_sub(margin) {
let mine = self.data[y * self.width + x];
let theirs = other.data[y * other.width + x];
for channel in 0..4 {
total += f64::from((mine[channel] - theirs[channel]).abs());
count += 1;
}
}
}
if count == 0 {
return 0.0;
}
(total / count as f64) as f32
}
}
fn cpu_blur(source: &Image, blur: &GaussianBlur) -> Image {
let mut image = source.clone();
let mut stack: Vec<(usize, usize)> = Vec::new();
for _ in 0..blur.n_decimations {
stack.push((image.width, image.height));
image = downscale(&image);
}
let kernel = &blur.kernel[..usize::from(blur.kernel_size)];
image = convolve(&image, kernel, Axis::X);
image = convolve(&image, kernel, Axis::Y);
while let Some(target) = stack.pop() {
image = upscale(&image, target);
}
image
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Axis {
X,
Y,
}
fn convolve(source: &Image, kernel: &[f32], axis: Axis) -> Image {
let radius = (kernel.len() / 2) as i64;
let mut out = Image::new(source.width, source.height);
for y in 0..source.height {
for x in 0..source.width {
let mut rgba = [0.0_f32; 4];
for (j, weight) in kernel.iter().enumerate() {
let offset = j as i64 - radius;
let texel = match axis {
Axis::X => source.sample(x as i64 + offset, y as i64),
Axis::Y => source.sample(x as i64, y as i64 + offset),
};
for channel in 0..4 {
rgba[channel] += texel[channel] * weight;
}
}
out.data[y * out.width + x] = rgba;
}
}
out.quantize()
}
fn downscale(source: &Image) -> Image {
let dst_width = source.width.div_ceil(2);
let dst_height = source.height.div_ceil(2);
let mut horizontal = Image::new(dst_width, source.height);
for y in 0..source.height {
for x in 0..dst_width {
let base = 2 * x as i64;
horizontal.data[y * dst_width + x] = binomial(
source.sample(base - 1, y as i64),
source.sample(base, y as i64),
source.sample(base + 1, y as i64),
source.sample(base + 2, y as i64),
);
}
}
let mut out = Image::new(dst_width, dst_height);
for x in 0..dst_width {
for y in 0..dst_height {
let base = 2 * y as i64;
out.data[y * dst_width + x] = binomial(
horizontal.sample(x as i64, base - 1),
horizontal.sample(x as i64, base),
horizontal.sample(x as i64, base + 1),
horizontal.sample(x as i64, base + 2),
);
}
}
out.quantize()
}
fn upscale(source: &Image, target: (usize, usize)) -> Image {
let mut horizontal = Image::new(source.width * 2, source.height);
for y in 0..source.height {
for x in 0..source.width {
let centre = source.sample(x as i64, y as i64);
let low = interpolate(source.sample(x as i64 - 1, y as i64), centre);
let high = interpolate(source.sample(x as i64 + 1, y as i64), centre);
horizontal.data[y * horizontal.width + 2 * x] = low;
horizontal.data[y * horizontal.width + 2 * x + 1] = high;
}
}
let mut doubled = Image::new(horizontal.width, source.height * 2);
for x in 0..doubled.width {
for y in 0..source.height {
let centre = horizontal.sample(x as i64, y as i64);
let low = interpolate(horizontal.sample(x as i64, y as i64 - 1), centre);
let high = interpolate(horizontal.sample(x as i64, y as i64 + 1), centre);
doubled.data[2 * y * doubled.width + x] = low;
doubled.data[(2 * y + 1) * doubled.width + x] = high;
}
}
let doubled = doubled.quantize();
let mut out = Image::new(target.0, target.1);
for y in 0..target.1 {
for x in 0..target.0 {
out.data[y * target.0 + x] = doubled.sample(x as i64, y as i64);
}
}
out
}
fn binomial(a: [f32; 4], b: [f32; 4], c: [f32; 4], d: [f32; 4]) -> [f32; 4] {
let mut out = [0.0_f32; 4];
for channel in 0..4 {
out[channel] = (a[channel] + 3.0 * b[channel] + 3.0 * c[channel] + d[channel]) / 8.0;
}
out
}
fn interpolate(neighbour: [f32; 4], centre: [f32; 4]) -> [f32; 4] {
let mut out = [0.0_f32; 4];
for channel in 0..4 {
out[channel] = 0.25 * neighbour[channel] + 0.75 * centre[channel];
}
out
}
fn cpu_drop_shadow(source: &Image, shadow: &DropShadow) -> Image {
let shifted = shift_image(source, shadow.dx, shadow.dy);
let blur = GaussianBlur {
std_deviation: shadow.std_deviation,
n_decimations: shadow.n_decimations,
kernel: shadow.kernel,
kernel_size: shadow.kernel_size,
edge_mode: shadow.edge_mode,
};
let blurred = cpu_blur(&shifted, &blur);
colorize(&blurred, shadow.color)
}
fn shift_image(source: &Image, dx: f32, dy: f32) -> Image {
let shift_x = (dx + 0.5).floor() as i64;
let shift_y = (dy + 0.5).floor() as i64;
let mut out = Image::new(source.width, source.height);
for y in 0..source.height {
for x in 0..source.width {
out.data[y * source.width + x] = source.sample(x as i64 - shift_x, y as i64 - shift_y);
}
}
out
}
fn colorize(source: &Image, color: AlphaColor<Srgb>) -> Image {
let premultiplied = color.premultiply().to_rgba8();
let channels = [
f32::from(premultiplied.r),
f32::from(premultiplied.g),
f32::from(premultiplied.b),
f32::from(premultiplied.a),
];
let mut out = Image::new(source.width, source.height);
for (dest, texel) in out.data.iter_mut().zip(source.data.iter()) {
let alpha = texel[3] / 255.0;
for channel in 0..4 {
dest[channel] = channels[channel] * alpha;
}
}
out.quantize()
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test filters -- --ignored`"]
fn a_blur_on_the_gpu_matches_the_shared_kernel_on_the_cpu() {
let _guard = render_lock();
let mut harness = FilterHarness::new();
let scope = harness
.device
.push_error_scope(wgpu::ErrorFilter::Validation);
let source = Image::with_rect(
FILTER_REGION.0 as usize,
FILTER_REGION.1 as usize,
FILTER_RECT,
[255.0, 0.0, 0.0, 255.0],
);
for sigma in SIGMAS {
let blur = GaussianBlur::new(sigma, EdgeMode::default());
let steps = blur_passes(
&blur,
SizeU16::from_wh(FILTER_REGION.0 as u16, FILTER_REGION.1 as u16),
);
harness.upload_source(&source);
let result = harness.run(&blur, &steps);
let gpu = harness.read_region(result);
let cpu = cpu_blur(&source, &blur);
let (worst, x, y) = gpu.worst_diff(&cpu, 0);
let mean = gpu.mean_diff(&cpu, 0);
println!(
"σ {sigma}: {} passes, worst per-channel diff {worst} at ({x}, {y}), mean {mean}",
steps.len()
);
assert!(
worst <= GPU_CPU_TOLERANCE,
"σ {sigma}: the GPU blur differs from the shared kernel's own by {worst} at ({x}, {y}), \
past the {GPU_CPU_TOLERANCE} eight-bit-rounding budget"
);
assert!(
mean <= GPU_CPU_MEAN_TOLERANCE,
"σ {sigma}: mean per-channel difference {mean} past {GPU_CPU_MEAN_TOLERANCE}"
);
let outside = gpu.sample(
i64::from(FILTER_RECT.0) - 2,
i64::from(FILTER_RECT.1 + FILTER_RECT.3 / 2),
);
assert!(
outside[3] > 0.0,
"σ {sigma}: nothing was painted outside the source rectangle — the pass produced no \
texels at all: {outside:?}"
);
let centre = gpu.sample(
i64::from(FILTER_RECT.0 + FILTER_RECT.2 / 2),
i64::from(FILTER_RECT.1 + FILTER_RECT.3 / 2),
);
assert!(
centre[3] > 64.0,
"σ {sigma}: the rectangle's own middle should stay the region's brightest texel by a \
wide margin: {centre:?}"
);
assert!(
gpu.data.iter().all(|texel| texel[3] <= centre[3]),
"σ {sigma}: some texel outranks the rectangle's own middle, so the blur is not \
centred where its source was"
);
assert!(
gpu.data
.iter()
.all(|texel| texel[0] == texel[3] && texel[1] == 0.0 && texel[2] == 0.0),
"σ {sigma}: premultiplied opaque red must stay red-equals-alpha through every pass"
);
let painted: f64 = gpu.data.iter().map(|texel| f64::from(texel[3])).sum();
let source_total: f64 = source.data.iter().map(|texel| f64::from(texel[3])).sum();
let drift = (painted - source_total).abs() / source_total;
println!("σ {sigma}: coverage drift {drift}");
assert!(
drift <= COVERAGE_DRIFT_TOLERANCE,
"σ {sigma}: the blur moved {drift} of the layer's total coverage, past the \
{COVERAGE_DRIFT_TOLERANCE} eight-bit-rounding budget — a normalized kernel conserves it"
);
}
let error = drain_error_scope(&harness.device, scope);
assert!(error.is_none(), "the filter passes raised {error:?}");
}
const GPU_CPU_TOLERANCE: f32 = 4.0;
const GPU_CPU_MEAN_TOLERANCE: f32 = 0.3;
const COVERAGE_DRIFT_TOLERANCE: f64 = 0.02;
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test filters -- --ignored`"]
fn a_zero_sigma_blur_returns_the_layer_unchanged_on_the_gpu() {
let _guard = render_lock();
let mut harness = FilterHarness::new();
let scope = harness
.device
.push_error_scope(wgpu::ErrorFilter::Validation);
let source = Image::with_rect(
FILTER_REGION.0 as usize,
FILTER_REGION.1 as usize,
FILTER_RECT,
[255.0, 0.0, 0.0, 255.0],
);
let blur = GaussianBlur::new(0.0, EdgeMode::default());
let steps = blur_passes(
&blur,
SizeU16::from_wh(FILTER_REGION.0 as u16, FILTER_REGION.1 as u16),
);
assert_eq!(steps.len(), 2, "an undecimated blur is one pass per axis");
harness.upload_source(&source);
let result = harness.run(&blur, &steps);
let gpu = harness.read_region(result);
let (worst, x, y) = gpu.worst_diff(&source, 0);
println!("σ 0: worst per-channel diff {worst} at ({x}, {y})");
assert!(
worst <= 1.0,
"an identity kernel must return the layer it was handed, but ({x}, {y}) moved by {worst}"
);
let error = drain_error_scope(&harness.device, scope);
assert!(error.is_none(), "the filter passes raised {error:?}");
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test filters -- --ignored`"]
fn a_decimated_pass_writes_its_own_extent_and_clears_the_border_around_it() {
let _guard = render_lock();
let mut harness = FilterHarness::new();
let scope = harness
.device
.push_error_scope(wgpu::ErrorFilter::Validation);
let source = Image::with_rect(
FILTER_REGION.0 as usize,
FILTER_REGION.1 as usize,
FILTER_RECT,
[255.0, 0.0, 0.0, 255.0],
);
let blur = GaussianBlur::new(8.0, EdgeMode::default());
let all = blur_passes(
&blur,
SizeU16::from_wh(FILTER_REGION.0 as u16, FILTER_REGION.1 as u16),
);
let downscales: Vec<frust_engine::filters::FilterStep> = all
.iter()
.take_while(|step| step.kind == FilterPassKind::Downscale)
.copied()
.collect();
assert_eq!(downscales.len(), 2, "σ 8 halves twice");
harness.upload_source(&source);
let result = harness.run(&blur, &downscales);
let page = harness.read_region(result);
let decimated = downscales[1].dest;
assert_eq!(
(decimated.width(), decimated.height()),
(112, 96),
"448 x 384 halved twice"
);
let inked = page.sample(
i64::from(decimated.width()) / 2,
i64::from(decimated.height()) / 2,
);
assert!(
inked[3] > 200.0,
"the decimated region's own middle should still be very nearly opaque: {inked:?}"
);
let padding = i64::from(FILTER_ATLAS_PADDING);
for offset in 0..8 {
let beyond = i64::from(decimated.width()) + padding + offset;
let texel = page.sample(beyond, i64::from(decimated.height()) / 2);
assert_eq!(
texel, [0.0; 4],
"texel ({beyond}, mid) is past the region and its padding, so it must be transparent"
);
}
let error = drain_error_scope(&harness.device, scope);
assert!(error.is_none(), "the filter passes raised {error:?}");
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test filters -- --ignored`"]
fn an_oversized_filter_layer_is_refused_by_the_real_pool_rather_than_the_driver() {
let _guard = render_lock();
let harness = FilterHarness::new();
let scope = harness
.device
.push_error_scope(wgpu::ErrorFilter::Validation);
let config = PageConfig::default();
let device_ceiling = frust_engine::gpu::targets::max_texture_size(&harness.caps);
let sigma = (device_ceiling as f32) / 2.0;
let mut recorder = recorder();
push_filter_layer(
&mut recorder,
layer(0.5),
LayerFilter::Blur { sigma },
Affine::IDENTITY,
)
.expect("a σ under the ceiling records");
draw(&mut recorder, 64, 64, 32);
recorder.pop_layer();
assert!(
u32::from(recorder.layers[0].bbox.width()) > device_ceiling,
"the blur has to be what pushes the layer past this adapter's device ceiling: {:?}",
recorder.layers[0].bbox
);
assert!(
matches!(
Schedule::build(&recorder, &harness.caps, &config),
Err(EngineError::IntermediateTextureTooLarge)
),
"a filter layer past the adapter's own device ceiling is refused rather than clipped \
onto it"
);
let mut targets = IntermediateTargets::new(&harness.caps);
let max = targets.max_texture_size();
let before = targets.stats().created;
let refused = targets.acquire(&harness.device, max + 1, 64, "oversized filter page");
assert!(refused.is_too_large());
assert_eq!(
targets.stats().created,
before,
"a refused request must allocate nothing on a real device either"
);
let error = drain_error_scope(&harness.device, scope);
assert!(
error.is_none(),
"refusing an oversized filter layer raised {error:?}"
);
}
const DROP_SHADOW_OFFSET: (f32, f32) = (16.0, -12.0);
const DROP_SHADOW_COLOR: AlphaColor<Srgb> = AlphaColor::new([0.0, 0.0, 1.0, 1.0]);
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test filters -- --ignored`"]
fn a_drop_shadow_on_the_gpu_matches_the_shared_kernel_on_the_cpu() {
let _guard = render_lock();
let mut harness = FilterHarness::new();
let scope = harness
.device
.push_error_scope(wgpu::ErrorFilter::Validation);
let source = Image::with_rect(
FILTER_REGION.0 as usize,
FILTER_REGION.1 as usize,
FILTER_RECT,
[255.0, 0.0, 0.0, 255.0],
);
for sigma in SIGMAS {
let shadow_value = shadow(DROP_SHADOW_OFFSET, sigma, DROP_SHADOW_COLOR);
let steps = drop_shadow_passes(
&shadow_value,
SizeU16::from_wh(FILTER_REGION.0 as u16, FILTER_REGION.1 as u16),
);
harness.upload_source(&source);
let block = GpuFilterData::from(GpuDropShadow::from(&shadow_value));
let result = harness.run_block(block, &steps);
let gpu = harness.read_region(result);
let cpu = cpu_drop_shadow(&source, &shadow_value);
let (worst, x, y) = gpu.worst_diff(&cpu, 0);
let mean = gpu.mean_diff(&cpu, 0);
println!(
"drop shadow σ {sigma}: {} passes, worst per-channel diff {worst} at ({x}, {y}), mean {mean}",
steps.len()
);
assert!(
worst <= DROP_SHADOW_GPU_CPU_TOLERANCE,
"σ {sigma}: the GPU drop shadow differs from the shared kernel's own by {worst} at \
({x}, {y}), past the {DROP_SHADOW_GPU_CPU_TOLERANCE} eight-bit-rounding budget"
);
assert!(
mean <= DROP_SHADOW_GPU_CPU_MEAN_TOLERANCE,
"σ {sigma}: mean per-channel difference {mean} past {DROP_SHADOW_GPU_CPU_MEAN_TOLERANCE}"
);
let shifted_x = i64::from(FILTER_RECT.0 as u16 + FILTER_RECT.2 as u16 / 2)
+ DROP_SHADOW_OFFSET.0.round() as i64;
let shifted_y = i64::from(FILTER_RECT.1 as u16 + FILTER_RECT.3 as u16 / 2)
+ DROP_SHADOW_OFFSET.1.round() as i64;
let centre = gpu.sample(shifted_x, shifted_y);
assert!(
centre[3] > 64.0,
"σ {sigma}: the shadow's own shifted middle should be substantially inked: {centre:?}"
);
assert!(
gpu.data.iter().all(|texel| texel[0] <= 1.0),
"σ {sigma}: a shadow texel carries red, so the source's own colour leaked through \
the colourize pass"
);
}
let error = drain_error_scope(&harness.device, scope);
assert!(error.is_none(), "the drop-shadow passes raised {error:?}");
}
const DROP_SHADOW_GPU_CPU_TOLERANCE: f32 = 4.0;
const DROP_SHADOW_GPU_CPU_MEAN_TOLERANCE: f32 = 0.3;
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test filters -- --ignored`"]
fn a_zero_sigma_zero_offset_drop_shadow_recolors_the_sources_own_shape_on_the_gpu() {
let _guard = render_lock();
let mut harness = FilterHarness::new();
let scope = harness
.device
.push_error_scope(wgpu::ErrorFilter::Validation);
let source = Image::with_rect(
FILTER_REGION.0 as usize,
FILTER_REGION.1 as usize,
FILTER_RECT,
[255.0, 0.0, 0.0, 255.0],
);
let shadow_value = shadow((0.0, 0.0), 0.0, DROP_SHADOW_COLOR);
let steps = drop_shadow_passes(
&shadow_value,
SizeU16::from_wh(FILTER_REGION.0 as u16, FILTER_REGION.1 as u16),
);
assert_eq!(
steps.len(),
4,
"an unshifted, undecimated shadow is one offset, one pass per blur axis, and one colourize"
);
harness.upload_source(&source);
let block = GpuFilterData::from(GpuDropShadow::from(&shadow_value));
let result = harness.run_block(block, &steps);
let gpu = harness.read_region(result);
let expected = colorize(&source, DROP_SHADOW_COLOR);
let (worst, x, y) = gpu.worst_diff(&expected, 0);
println!("drop shadow σ 0, offset 0: worst per-channel diff {worst} at ({x}, {y})");
assert!(
worst <= 1.0,
"an identity blur at zero offset must recolour the source's own shape unchanged, but \
({x}, {y}) moved by {worst}"
);
let error = drain_error_scope(&harness.device, scope);
assert!(error.is_none(), "the drop-shadow passes raised {error:?}");
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test filters -- --ignored`"]
fn a_blur_at_the_regions_own_origin_still_reads_transparent_past_its_near_edge() {
let _guard = render_lock();
let mut harness = FilterHarness::new();
let scope = harness
.device
.push_error_scope(wgpu::ErrorFilter::Validation);
let source = Image::with_rect(
FILTER_REGION.0 as usize,
FILTER_REGION.1 as usize,
FILTER_RECT_AT_ORIGIN,
[255.0, 0.0, 0.0, 255.0],
);
for sigma in SIGMAS {
let blur = GaussianBlur::new(sigma, SERVED_EDGE_MODE);
let steps = blur_passes(
&blur,
SizeU16::from_wh(FILTER_REGION.0 as u16, FILTER_REGION.1 as u16),
);
harness.upload_source(&source);
let result = harness.run(&blur, &steps);
let gpu = harness.read_region(result);
let cpu = cpu_blur(&source, &blur);
let (worst, x, y) = gpu.worst_diff(&cpu, 0);
let mean = gpu.mean_diff(&cpu, 0);
println!(
"origin σ {sigma}: {} passes, worst per-channel diff {worst} at ({x}, {y}), mean {mean}",
steps.len()
);
assert!(
worst <= GPU_CPU_TOLERANCE,
"σ {sigma}: a blur at the region's own origin differs from `EdgeMode::None` by \
{worst} at ({x}, {y}), past the {GPU_CPU_TOLERANCE} eight-bit-rounding budget — a \
tap past the near edge read the region's own content instead of transparent black"
);
assert!(
mean <= GPU_CPU_MEAN_TOLERANCE,
"σ {sigma}: mean per-channel difference {mean} past {GPU_CPU_MEAN_TOLERANCE}"
);
let interior_x = i64::from(FILTER_RECT_AT_ORIGIN.2) / 2;
let middle_y = i64::from(FILTER_RECT_AT_ORIGIN.3) / 2;
let edge = gpu.sample(0, middle_y);
let interior = gpu.sample(interior_x, middle_y);
println!(
"origin σ {sigma}: edge alpha {} vs interior {}",
edge[3], interior[3]
);
assert!(
interior[3] > 32.0,
"σ {sigma}: the interior has to be inked for the comparison below to mean anything: \
{interior:?}"
);
assert!(
edge[3] < 0.75 * interior[3],
"σ {sigma}: the region's own edge column is {} against an interior of {}, which is \
what replicating the edge texel into the taps past it looks like — the near-side \
taps must read transparent black",
edge[3],
interior[3]
);
}
let error = drain_error_scope(&harness.device, scope);
assert!(error.is_none(), "the filter passes raised {error:?}");
}