Swirl has been one of our most popular tools. Because of that, we are making it a reusable effect throughout BrushCue.
What Changed
Previously, Swirl was implemented as a Custom Transformer Shader. While that approach worked, it made the effect harder to incorporate into your own projects and limited how interactive it could be in the editor. Swirl is now a dedicated operation and is also available via the Python API. This opens the door to wiring it into more complex graphs, animating its parameters with expressions, and calling it directly from scripts.
The three controls
Center
The center point sets the origin of the swirl. You can drag a handle directly on the canvas to position it interactively. The distortion radiates outward from this point, so moving the center will shift the entire effect.
Radius
Radius controls how far the swirl extends from the center point. Smaller values concentrate the distortion in a tight region; larger values spread it across much of the image. At the boundary of the radius, the twist tapers smoothly to zero so the effect blends naturally into the surrounding image.
Amount
Amount sets the strength of the rotation — how many radians each pixel near the center is twisted. Positive and negative values rotate in opposite directions. Small values produce a gentle spiral; large values wrap the image into a full vortex.
How the Shader Works
The Swirl effect is a pure inverse-warp shader: for each output pixel we calculate where it came from in the original image, then sample there.
@group(0) @binding(0) var input_texture: texture_2d<f32>;
@group(0) @binding(1) var<uniform> center_point: vec2<f32>;
@group(0) @binding(2) var<uniform> swirl_radius: f32;
@group(0) @binding(3) var<uniform> swirl_amount: f32;
fn do_transformation(input: vec4<f32>, position: vec2<u32>) -> vec4<f32> {
let center = center_point;
let dims = vec2<f32>(textureDimensions(input_texture, 0));
let aspect = dims.x / dims.y;
let uv = vec2<f32>(position) / dims;
let delta = uv - center;
// Scale x by aspect ratio so distance/angle are in square (pixel) space
let delta_aspect = vec2<f32>(delta.x * aspect, delta.y);
let dist = length(delta_aspect);
let angle = atan2(delta_aspect.y, delta_aspect.x);
// Normalise distance to the user-defined radius
let effective_radius = max(swirl_radius * 0.5, 0.0001);
let t = clamp(dist / effective_radius, 0.0, 1.0);
// Smooth quartic falloff — full twist at center, zero at the radius boundary
let falloff = (1.0 - t * t) * (1.0 - t * t);
// Inverse warp: subtract the swirl angle to find the source pixel
let twist = falloff * swirl_amount;
let src_angle = angle - twist;
// Unscale x back from aspect space when reconstructing the source UV
let src_uv = center + dist * vec2<f32>(cos(src_angle) / aspect, sin(src_angle));
// Return transparent if the source UV is out of bounds
if (src_uv.x < 0.0 || src_uv.x > 1.0 || src_uv.y < 0.0 || src_uv.y > 1.0) {
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
return sample(input_texture, src_uv);
}
fn sample(tex: texture_2d<f32>, uv: vec2<f32>) -> vec4<f32>; // function implemented for you