Skip to content
Distort · Swirl, pinch & bulgeBeginner

Whirlpool

Foam arms that spiral into a dark eye, drawn on a flat quad by turning the angle around the center more the closer it gets.

UVTimeSurface
Open in Playground
  1. 01GoalDistort
  2. 02TechniqueSwirl, pinch & bulge
  3. 03ExecutionWhirlpool
Full-screen quad

Parameters

6.0
0.45
1.5
5
#051a2e
#1a7399
#d9f2ff

Copy for your platform

  • GLSLCopy
  • GodotCopy

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. lengthLengthlengthlengthlength
    UV
    Time

    01 Distance from the center

  2. atanATan2atanatanatan

    02 Angle around it

  3. *Multiply***

    03 Twist by distance

  4. cosCoscoscoscos

    04 Arms in the turned angle

  5. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    05 Foam lines

  6. mixMixmixmixmix

    06 Water and foam

How it works

A whirlpool is a pattern drawn in polar coordinates. Straight arms are easy there: anything that depends only on the angle radiates from the center like spokes. Turning that angle by an amount that grows toward the center bends the spokes into spirals.

1. Distance from the center. length(uv - 0.5) is 0 in the middle of the quad and 0.5 at the middle of each edge. The preview shows it as a gray ramp growing outward from a black center.

2. Angle around it. atan(p.y, p.x) is the direction of the pixel seen from the center, from -π to π. The preview shows it as is, clamped to 0..1: black below the center, where the angle is negative, then a ramp from the right edge that turns white after one radian.

3. Twist by distance. The falloff 1.0 - smoothstep(0.0, reach, radius) is 1 at the center and 0 at the reach and beyond. Squared and multiplied by the twist, it says how many radians each ring turns. Squaring concentrates the turn near the eye, as in a real vortex, where water near the drain spins fastest. Outside the reach the turn is exactly 0, so the calm water joins the spiral without a seam. The preview is a white disc fading to black at the reach: inside about half the reach the turn is above one radian.

4. Arms in the turned angle. cos(turned * arms) repeats a whole number of times around the circle, so each repeat is one arm. The turned angle is the original angle plus the twist plus time. Because the twist changes with the radius, each arm’s ridge sits at a different angle on each ring, and the ridges form spirals. The arm count is rounded with floor: with a fractional count, the arms wouldn’t meet themselves at the seam where atan jumps from π to -π, and a cut would show.

5. Foam lines. smoothstep(0.55, 1.0, arms) keeps only the ridge of each arm, a thin bright line. Two more smoothsteps fade the foam out where the falloff ends, so the calm water outside has none, and near the very center, where the arms crowd into a point that would flicker.

6. Water and foam. A ramp from deep to surface water by distance darkens the eye, and the foam is mixed over it.

Why the spin is added outside the twist

Time is added to the angle by itself, not multiplied into the twist. That turns every ring by the same angle each second, so the whole pattern rotates rigidly. If the spin grew toward the center like the twist, the inner rings would gain on the outer ones every frame and the arms would wind tighter forever, until each arm was thinner than a pixel and the center turned to noise. A real whirlpool’s water does move faster near the eye; the picture holds still because the arms here are a pattern, not tracked particles of water.

Tuning and limits

About 50 math operations per pixel, no texture reads, one pass. atan is the expensive one, a short polynomial on most GPUs; the rest is cheap arithmetic.

  • Twist sets how many turns the arms make between the reach and the eye: about twist / 2π. Above about 12 the inner turns get thinner than the foam line and start to shimmer; widen the foam’s smoothstep or blur the eye.
  • A negative twist or speed turns the vortex the other way.
  • The center is a singularity. At the exact center the angle is undefined; atan(0, 0) returns 0 on most GPUs but isn’t guaranteed to. The foam fades out before it gets there, which hides both that and the crowded arms.
  • Swirling a texture instead of drawing arms: turn the angle the same way, rebuild a coordinate with 0.5 + radius * vec2(cos(turned), sin(turned)) and sample there. That is the twirl node of node editors.

Make it in GLSL

The copied shader is a complete WebGL2 fragment shader for a quad: it declares the inputs, one uniform per param and u_time, and writes the sRGB-encoded color.

  1. Draw a square quad whose vertex shader passes the UV, 0..1 from the bottom-left, as v_uv.
  2. Compile the copied code as the fragment shader and link it with that vertex shader.
  3. Each frame, set u_time to the seconds since the start, then draw. Set the p_ uniforms once, or when a slider changes; colors go in linear.
  4. To place the vortex somewhere else, replace the 0.5 in i.uv - 0.5 with a vec2 uniform; to make it bigger or smaller, change the reach.

For a whirlpool over your own water, return the foam amount as alpha instead of mixing it over the water, and draw with gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA).

Make it in Godot

The shader is a canvas_item shader that assumes HDR 2D is off (the project default); it decodes the colors and encodes the result itself.

The exported shader draws on any 2D node with a material. A square ColorRect is the simplest: it has no texture of its own and its UV covers it from corner to corner.

  1. Add a ColorRect to a 2D scene or a UI and give it a square size, such as 512 by 512.
  2. In the Inspector, set Material to a new ShaderMaterial, and its Shader to a new Shader.
  3. Paste the copied code into the shader. The arms start turning at once: Godot’s TIME drives i.time.
  4. Tune the params under Shader Parameters: the same names as on this card, without the p_ prefix.

To build it as a VisualShader instead (a canvas_item VisualShader on the same material):

  1. UV minus a vec2 constant of 0.5: the centered coordinate. Flip its y first with a OneMinus if the spiral must turn the same way as here, since Godot’s UV starts at the top-left.
  2. Length of it: the radius. VectorDecompose and ATan2 with y first: the angle.
  3. SmoothStep of the radius from 0 to the reach, then OneMinus, then Multiply by itself and by the twist: the turn.
  4. Add the turn and Time times the speed to the angle, Multiply by the arm count and take its Cos, remapped to 0..1: the arms.
  5. SmoothStep the arms for the foam, then two Mix nodes: deep to surface water by the radius, then the foam over it, into Color.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

whirlpool.fragGLSL
#version 300 es// Swirl, pinch & bulge → Whirlpool, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_twist; // Twist: 6.0 (-15.0 to 15.0)uniform float p_reach; // Reach: 0.45 (0.1 to 0.7)uniform float p_speed; // Spin speed: 1.5 (-6.0 to 6.0)uniform float p_armCount; // Arms: 5.0 (1.0 to 12.0)uniform vec3 p_deep; // Deep water: vec3(0.02, 0.1, 0.18), sRGB; upload it decoded to linearuniform vec3 p_shallow; // Surface water: vec3(0.1, 0.45, 0.6), sRGB; upload it decoded to linearuniform vec3 p_foam; // Foam: vec3(0.85, 0.95, 1.0), sRGB; upload it decoded to linearin vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}vec4 effect(Inputs i) {	vec2 p = i.uv - 0.5;	float radius = length(p);	float angle = atan(p.y, p.x);	// 1 at the center, 0 at p_reach and beyond: the water outside the whirlpool isn't turned	float falloff = 1.0 - smoothstep(0.0, p_reach, radius);	// Squared, so the twist builds up sharply toward the eye, as in a real vortex	float swirl = falloff * falloff * p_twist;	// The spin turns every radius by the same angle: a spin that grew toward the center would wind the arms tighter	// every second, until they were thinner than a pixel	float turned = angle + swirl + i.time * p_speed;	// A whole number of arms, so cos() meets itself where atan() jumps from pi to -pi	float arms = 0.5 + 0.5 * cos(turned * floor(p_armCount));	float froth = smoothstep(0.55, 1.0, arms) * smoothstep(0.0, 0.3, falloff) * smoothstep(0.03, 0.15, radius);	float depth = smoothstep(0.02, p_reach * 0.7, radius);	vec3 water = mix(p_deep, p_shallow, depth);	vec3 color = mix(water, p_foam, froth * 0.8);	return vec4(color, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
whirlpool.gdshaderGodot shader
// Swirl, pinch & bulge → Whirlpool, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform float twist : hint_range(-15.0, 15.0, 0.1) = 6.0;uniform float reach : hint_range(0.1, 0.7, 0.01) = 0.45;uniform float speed : hint_range(-6.0, 6.0, 0.1) = 1.5;uniform float armCount : hint_range(1.0, 12.0, 1.0) = 5.0;uniform vec3 deep : source_color = vec3(0.02, 0.1, 0.18);uniform vec3 shallow : source_color = vec3(0.1, 0.45, 0.6);uniform vec3 foam : source_color = vec3(0.85, 0.95, 1.0);struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) {	return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}vec4 effect(Inputs i) {	vec2 p = i.uv - 0.5;	float radius = length(p);	float angle = atan(p.y, p.x);	// 1 at the center, 0 at reach and beyond: the water outside the whirlpool isn't turned	float falloff = 1.0 - smoothstep(0.0, reach, radius);	// Squared, so the twist builds up sharply toward the eye, as in a real vortex	float swirl = falloff * falloff * twist;	// The spin turns every radius by the same angle: a spin that grew toward the center would wind the arms tighter	// every second, until they were thinner than a pixel	float turned = angle + swirl + i.time * speed;	// A whole number of arms, so cos() meets itself where atan() jumps from pi to -pi	float arms = 0.5 + 0.5 * cos(turned * floor(armCount));	float froth = smoothstep(0.55, 1.0, arms) * smoothstep(0.0, 0.3, falloff) * smoothstep(0.03, 0.15, radius);	float depth = smoothstep(0.02, reach * 0.7, radius);	vec3 water = mix(u_toLinear(deep), u_toLinear(shallow), depth);	vec3 color = mix(water, u_toLinear(foam), froth * 0.8);	return vec4(color, 1.0);}void fragment() {	Inputs i;	// UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(UV.x, 1.0 - UV.y);	// A flat quad facing the viewer, the same as the site's 2D preview	i.position = vec3(i.uv * 2.0 - 1.0, 0.0);	i.normal = vec3(0.0, 0.0, 1.0);	i.view = vec3(0.0, 0.0, 1.0);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = 1.0 / SCREEN_PIXEL_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading