prgpu 0.1.11

GPU-accelerated rendering utilities for Adobe Premiere Pro and After Effects plugins
//! Shared helpers for separable Gaussian blur passes.
//!
//! Picks the intermediate buffer size when ping-ponging horizontal/vertical
//! Gaussians through a downsampled buffer.

/// Floor of the radius-driven scale factor.
///
/// Below 25% of full size on each axis, even strong Gaussians reveal aliasing
/// ("swimming" / chunky bokeh) on smooth gradients in motion.
const RADIUS_FACTOR_FLOOR: f32 = 0.25;

/// Target effective sigma in downsampled space, in texels.
///
/// At σ ≥ ~2 downsampled-texels the Gaussian's high-frequency tail sits below
/// the post-downsample Nyquist limit, so further downsampling is invisible.
/// Below 2, downsample artifacts (blocky bokeh, swimming) start to show.
const TARGET_DOWNSAMPLED_SIGMA: f32 = 2.0;

#[derive(Debug, Clone, Copy)]
pub struct BlurDownsampleInputs {
	pub width: u32,
	pub height: u32,
	/// Effective Gaussian sigma in full-resolution texels (post host-downsample).
	/// Typically `blur_radius / host_downsample_x`.
	pub sigma_full: f32,
	/// Quality slider in `[0, 100]` (percent of full resolution).
	pub quality_pct: f32,
}

#[derive(Debug, Clone, Copy)]
pub struct BlurDownsample {
	pub width: u32,
	pub height: u32,
	pub quality_factor: f32,
	pub radius_factor: f32,
}

impl BlurDownsample {
	pub fn combined_factor(&self) -> f32 {
		self.quality_factor * self.radius_factor
	}
}

/// Map full-resolution sigma to a `[FLOOR, 1.0]` scale that hides resolution
/// loss inside the blur's own low-pass response.
///
/// The kernel removes frequencies above ~`1/(2πσ)`. Downsampling by `s` puts
/// the new Nyquist at `0.5/s`, so keeping σ_downsampled ≥ TARGET_DOWNSAMPLED_SIGMA
/// (≥ 2) gives ~2× headroom, i.e. `s ≥ target / σ_full` before clamping.
#[inline]
pub fn radius_scale_factor(sigma_full: f32) -> f32 {
	if sigma_full <= TARGET_DOWNSAMPLED_SIGMA {
		// Tiny blur: any extra downsample shows stair-stepping before the kernel can mask it.
		return 1.0;
	}
	(TARGET_DOWNSAMPLED_SIGMA / sigma_full).clamp(RADIUS_FACTOR_FLOOR, 1.0)
}

/// Intermediate buffer size for a separable Gaussian.
///
/// `final_dims = full_dims × clamp(quality_pct/100, 0.01, 1) × radius_scale_factor(sigma_full)`.
/// Both factors are reported back so callers can log/display them.
pub fn compute_downsample(inputs: BlurDownsampleInputs) -> BlurDownsample {
	let quality_factor = (inputs.quality_pct / 100.0).clamp(0.01, 1.0);
	let radius_factor = radius_scale_factor(inputs.sigma_full);
	let scale = quality_factor * radius_factor;

	let w = ((inputs.width as f32 * scale) as u32).max(1);
	let h = ((inputs.height as f32 * scale) as u32).max(1);

	BlurDownsample {
		width: w,
		height: h,
		quality_factor,
		radius_factor,
	}
}

#[cfg(test)]
mod tests {
	use super::*;

	fn approx_eq(a: f32, b: f32) -> bool {
		(a - b).abs() < 1e-5
	}

	#[test]
	fn tiny_sigma_keeps_full_res() {
		assert!(approx_eq(radius_scale_factor(0.5), 1.0));
		assert!(approx_eq(radius_scale_factor(2.0), 1.0));
	}

	#[test]
	fn medium_sigma_halves_resolution() {
		assert!(approx_eq(radius_scale_factor(4.0), 0.5));
	}

	#[test]
	fn huge_sigma_clamps_to_floor() {
		assert!(approx_eq(radius_scale_factor(64.0), RADIUS_FACTOR_FLOOR));
		assert!(approx_eq(radius_scale_factor(1024.0), RADIUS_FACTOR_FLOOR));
	}

	#[test]
	fn combined_factors_multiply() {
		let ds = compute_downsample(BlurDownsampleInputs {
			width: 1920,
			height: 1080,
			sigma_full: 8.0,
			quality_pct: 50.0,
		});
		assert!(approx_eq(ds.quality_factor, 0.5));
		assert!(approx_eq(ds.radius_factor, 0.25));
		assert!(approx_eq(ds.combined_factor(), 0.125));
		assert_eq!(ds.width, 240);
		assert_eq!(ds.height, 135);
	}

	#[test]
	fn min_one_texel() {
		let ds = compute_downsample(BlurDownsampleInputs {
			width: 4,
			height: 4,
			sigma_full: 256.0,
			quality_pct: 1.0,
		});
		assert!(ds.width >= 1 && ds.height >= 1);
	}
}