A spirals bonanza from a few simple polar formulas

A bunch of fun and simple spiral visual effects from a little bit of math. Godot shader code included

I spent a little time yesterday exploring creating spirals with shaders, and found a few interesting “happy accidents” along the way. The shader code and full explanation is posted to GodotShaders.com. Or you can skip to the shader code.

A selection of spiral patterns created by the shader

Understanding spirals is a lovely application of polar coordinates. In polar coordinates, we denote a point on the plane not by its “rectangular/Cartesian” \((x,y)\) coordinates (which indicate the displacement horizontally and vertically from the origin), but by tracking its distance from the origin, called \(r\) for radius, and angle (usually counterclockwise) it makes with the ray formed by the positive \(x\) axis, which we call \(\theta\). So the point is \((r,\theta)\). We can then draw a curve by creating a function involving the two variables, which usually means that we make \(r\) a function of \(\theta\) (in the same way that \(y\) is usually a function of \(x\). So a point in the plane is on the curve when its radius matches that function… so it’s all about determining the appropriate radii based on the angles. There are many kinds of spirals, but the simplest just says that as the angle increases, the radius should increase. (The beauty of how we represent angles is that the same directions are represented by infinitely many angles, for example 0, 360, 720, and -360, etc, all represent the same direction. Our “spiral” effect comes from this fact… walking out along a specific direction, we will hit infinitely many radii that correspond to different angle measures for the same direction.)

The simplest polar function is therefore \(r = \theta\).

Converting to polar coordinates in a shader

In a shader, we’re usually setting a color or transparency based on a single value, so instead of an equation, we look at the output of \(r – \theta\). Except of course, we have to be careful that these things match up in our shader’s coordinate system.

Since we want our spiral to eminate from the center of our texture, which in a shader’s UV coordinates is the point \((.5,.5)\), we first come up with appropriate definitions of r and theta: \( r= \text{length}(UV – \text{vec2}(.5)) \), and \(\theta = \text{atan}(UV.y-.5, UV.x-.5)\). Notice I’m using a two-parameter arctangent function. Most students of math will be familiar with the “standard” one-parameter arctangent, which takes as input a tangent value — recall, the tangent of an angle in a right triangle is the ratio “opposite over adjacent”, usually \(\frac{y}{x}\)) — and returns an angle. The problem with this is that it can only output values from \(-\frac{\pi}{2}\) to \(\frac{\pi}{2}\) because it is not tracking \(x\) and \(y\) separately and therefore doesn’t know when their signs (+ or -) are different, which determines the quadrant. This can make things messy if you need the full 360 degrees worth of angles, so the two-parameter solves this by tracking the numerator and denominator of the tangent ratio to determine an exact angle.

Spirals Demo Shader Code

shader_type canvas_item;

uniform int  spiral_type : hint_range(1,5) = 1;
uniform vec3 color : source_color;
uniform float rays : hint_range(0.,20., 1) = 6;
uniform float speed : hint_range(0., 20., .01) = .5;
uniform float fade : hint_range(0., 3., .01) = .1;
uniform float thickness : hint_range(0., 1., .01) = .3;
uniform bool clockwise = true;

// not used by all spirals
uniform float tiers: hint_range(0., 20., 1) = 4;
uniform float stretch : hint_range(0., 10., .1) = 6.28;
uniform vec3 s5color2 : source_color;

void fragment() {
	float r = length(.5 - UV);
	float angle = atan(UV.y-.5, UV.x-.5);
	COLOR.rgb = color;
	
	if (spiral_type == 1)
		COLOR.a = 1. - smoothstep(-.1, .1, 
                  fract((2.*r-(angle+PI)/TAU)*rays + 
				  (clockwise?1.:-1.)* TIME*speed)-thickness);
	if (spiral_type == 2)
		COLOR.a = 1. - smoothstep(0., thickness, 
                  abs(.5 - fract((2.*r-(angle+PI)/TAU)*rays + 
                  (clockwise?1.:-1.)* TIME*speed) ) );
	if (spiral_type == 3)
		COLOR.a *= 1. - smoothstep(0.0, thickness, 
                   fract(tiers*(2.*r))/tiers - mod(tiers*(angle+PI)- 
                   (clockwise?1.:-1.)*TIME,TAU)/(stretch*tiers) );
	if (spiral_type == 4)
		COLOR.a *= 1. - smoothstep(0.0, thickness, 
                   abs(fract(tiers*(2.*r))/tiers - mod(tiers*(angle+PI)- 
                   (clockwise?1.:-1.)*TIME,TAU)/(stretch*tiers)) );
	if (spiral_type == 5)
		COLOR.rgb = mix(color, s5color2, 
                    thickness*fract((2.*r-(angle+PI)/TAU)*rays + 
					(clockwise?1.:-1.)* TIME*speed) );
	
	COLOR.a *= pow(2.*(.5 - r), fade*4.);
}

You’ll notice in the code, I use TAU, which is simply \(2 \pi\).