Back to Articles

2026-05-08

Introducing the Swirl Tool

By Anthony Dito

A new Swirl tool that applies a smooth rotational distortion with a draggable center handle, a radius, and a rotation amount.

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