Foundations, part 4

Rings, rays and spirals with polar coordinates

Give every pixel an angle and a distance from a centre, and suddenly rings, rays, spirals, rings of copies and even a heart take a line or two of math.

  • Part 47 min dose
  • Beginner

I keep running into round things in shaders: halos, dials, rings of tick marks, spirals. Each one is a struggle with x and y, and surprisingly short with two other numbers, the pixel’s angle around a centre and its distance from it. Those two are called polar coordinates. Let’s use them to draw rings, rays, spirals, a ring of copies and, why not, a heart.

A magic circle in polar coordinates

Turn on JavaScript to run this demo.

lib.hlsl and hero.hlsl draw the demo above
Fig. 1

The rays outside, the 60 ticks, the six small circles and the spiral in the middle all come from angle and radius. By the end, you’ll know how to make each piece.

Two numbers instead of x and y

Normally a pixel sits at a position p=(x,y)p = (x, y). You can describe the very same spot by its radius rr and its angle aa:

r=∣p∣a=atan⁡(y,x)\begin{aligned} r &= |p| \\ a &= \operatorname{atan}(y, x) \end{aligned}

rr is the distance from the centre, the same one you met in the circle distance. aa is the direction, in radians. Radians are the angle unit shaders use. A full turn is 2π2\pi (about 6.28) where degrees would say 360. 0 points right and π/2\pi/2, a quarter turn, points up. atan(y, x) turns a direction into its angle; Unity calls it atan2.

Go on, drag the point. The pink line is rr and the butter arc is aa.

Drag the point round the centre
Drag the marked part, or press Tab to reach it and use the arrow keys

Turn on JavaScript to run this demo.

What the background brightness follows.
polar.hlsl draws the demo above
Fig. 2

With Shade by on Angle a, follow the mint round. It gets stronger all the way round, then jumps back on the dashed line.

The angle runs from −π-\pi to π\pi, so somewhere it has to jump. atan jumps on the left, along the dashed line. I call that line the seam. Keep it in mind, because it comes back.

Rings, rays and spirals

Time to meet fract(x), the star of this section. It keeps only how far xx is past the whole number below it, so as xx grows, it counts 0 to 1 over and over. That works below zero too, though it looks odd at first. The whole number below −0.3 is −1, so fract(-0.3) is 0.7. Feed it the radius and you get rings. Feed it the angle and you get rays.

rings=fract⁡(n r)rays=fract⁡(m a/2π)spiral=fract⁡(m a/2π+n r)\begin{aligned} \text{rings} &= \operatorname{fract}(n\,r) \\ \text{rays} &= \operatorname{fract}(m\,a / 2\pi) \\ \text{spiral} &= \operatorname{fract}(m\,a / 2\pi + n\,r) \end{aligned}

nn counts rings per unit of radius, mm rays per turn, and a/2πa / 2\pi is the angle counted in turns. fract gives a ramp from 0 to 1 in every repeat, and the demo colors its middle half, so stripes and gaps come out equally wide. Now the fun one. Add the two together and the rings slide as the angle grows. After 1/m1/m of a turn each ring has reached the place of the next, so they join into mm spiral arms.

Rings, rays and spirals

Turn on JavaScript to run this demo.

How many stripes per unit of radius.4
How many stripes once around. Try a value that is not a whole number.3.0
patterns.hlsl draws the demo above
Fig. 3

Try each Pattern (Checker crosses rings with rays). Then go back to Spiral and set Rays m to 7.5. On the left, the stripes no longer meet. Hello again, seam.

The seam, and two ways round it

Remember the seam? It’s the dashed line on the left, where the angle jumps by 2π2\pi.

The first way: use whole counts. When mm is a whole number, the jump of 2π2\pi in aa adds exactly mm to the number inside fract. fract throws whole numbers away, so the stripes don’t change at all. That’s why counts around a circle are whole numbers. The radius has no seam, so nn can be anything.

The second way: wrap the difference. Say you want a soft wedge of light around an angle gg. For that you need the distance between two angles, and near the seam the plain ∣a−g∣|a - g| gets it wrong, because 179°179° and −179°-179° are 2 degrees apart, not 358. So let’s wrap it first:

Δ=∣fract⁡ ⁣(a−g2π+12)−12∣⋅2π\Delta = \left|\operatorname{fract}\!\left(\tfrac{a - g}{2\pi} + \tfrac12\right) - \tfrac12\right| \cdot 2\pi

This one looks scarier than it is, so let’s take it slowly. Dividing by 2π2\pi counts the difference in turns. fract⁡(…+12)−12\operatorname{fract}(\ldots + \tfrac12) - \tfrac12 drops whole turns and leaves a number between −12-\tfrac12 and 12\tfrac12. Multiplying by 2π2\pi makes it an angle again. What comes out, Δ\Delta, is the shorter way round, between 0 and π\pi. The wedge is brightest at gg and fades out as Δ\Delta reaches the width ww.

A lighthouse beam sweeping round

Turn on JavaScript to run this demo.

Where the beam points at the start, in radians. 0 is right and 3.14 is left.2.00
How fast the beam turns, in radians per second.0.60
How far from its centre angle the beam fades out, in radians.0.90
Measure the shorter way round the circle, so the beam survives the seam.
wedge.hlsl draws the demo above
Fig. 4

Set Spin to 0 and turn Wrap off, then move Angle g close to 3.14. See how half of the wedge is cut off at the seam? Turn Wrap back on and it is whole again.

Plain and wrapped angle difference

Turn on JavaScript to run this demo.

The angle the wedge points at, in radians.-2.00
Code for the plot above
  • f1(x):abs(x - _G)
  • f2(x):abs(frac((x - _G) / 6.2831853 + 0.5) - 0.5) * 6.2831853
Fig. 5

Move Angle g and watch the far end, where the plain difference keeps climbing towards 2π2\pi and the wrapped one turns back down.

Draw once, copy all the way round

Here comes a lovely trick. A ring of 60 ticks is not drawn one tick after another. Instead, each pixel works out which slice of the circle it is in, then turns itself back into the first slice. With mm slices (the same mm as before, now counting copies) of size s=2π/ms = 2\pi / m:

k=round⁡(a/s)q=rotate⁡(p, −k s)\begin{aligned} k &= \operatorname{round}(a / s) \\ q &= \operatorname{rotate}(p,\,-k\,s) \end{aligned}

kk is the number of the slice, and qq is the same pixel turned back by kk slices. Now draw the one shape with qq in place of pp, and it shows up in every slice. As a bonus, kk gives every copy its own number, so each tick can have its own length or brightness.

One shape copied round the circle

Turn on JavaScript to run this demo.

How many slices the circle is cut into.60
The one shape every slice draws.
Shade every other slice and draw the borders between them.
ticks.hlsl draws the demo above
Fig. 6

Play with Copies m and Shape (yes, there is a heart), and turn on Show slices to see where each copy lives.

There’s one catch. A pixel only sees the copy in its own slice, so keep each copy inside its slice or it gets cut off. You may know this way of repeating a shape as pModPolar, from Mercury’s hg_sdf library.

A heart, one radius per angle

A shape can also be described by one edge radius for every angle. Inigo Quilez found a heart this way. Measure the angle hh from straight up, as a fraction of half a turn, so it is 0 at the top and 1 at the bottom:

h=∣atan⁡(x,y)∣/πredge=13h−22h2+10h36−5h\begin{aligned} h &= |\operatorname{atan}(x, y)| / \pi \\ r_{edge} &= \frac{13h - 22h^2 + 10h^3}{6 - 5h} \end{aligned}

Those numbers only bend the curve into a heart shape, so don’t worry about them. A pixel is inside when r<redger < r_{edge}, with rr still the pixel’s distance from the centre. At the top redger_{edge} is 0, which makes the dip. At the bottom it is 1, which makes the tip. The swapped inputs atan(x, y) turn angle 0 to point up, and the absolute value (the bars, which drop the minus sign) makes the left and right halves mirror each other.

A heart from its edge radius
Drag the marked part, or press Tab to reach it and use the arrow keys

Turn on JavaScript to run this demo.

Squash and stretch the heart with a wobble that fades out, once every 1.5 seconds.
heart.hlsl draws the demo above
Fig. 7

Drag the point round the heart. The pink spoke is redger_{edge} for that angle. Beat squashes the picture with a wobble that fades out, once every 1.5 seconds.

This redger_{edge} is not a true distance, though, so outlines and glows come out uneven. For those, a heart distance field works better.

In practice

Once you start looking, polar coordinates turn up wherever something is round: dials and clock faces, halos, spinning rings, magic circles, and the rings and rays of an eye. On a mesh, pp is usually the UV minus the centre of the circle. Three things to watch for in Unity:

  • Stretched UVs. If the mesh part is wider or taller than its UV island, every ring becomes an oval. Multiply one axis of pp by that stretch first.
  • Textures across the seam. The seam is back. Used as a texture coordinate, the angle’s jump looks like a huge change to the graphics card. It picks its blurriest copy of the texture there and draws a thin line along the seam. tex2Dgrad lets you tell it how fast the coordinate really changes. Or draw the pattern with math, as here.
  • The centre. Rays and spiral arms all meet at r=0r = 0, where they get thinner than a pixel and flicker. Fade them out near the centre, or cover it up.

Here are the two helpers from this article, written for Unity:

// Which of 'count' slices p is in, and p turned back into slice 0.
float2 PolarRepeat(float2 p, float count, out float index)
{
    float slice = UNITY_TWO_PI / count;
    float k = round(atan2(p.y, p.x) / slice);
    index = k - count * floor(k / count);   // 0 to count - 1
    float s, c;
    sincos(-k * slice, s, c);
    return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}

// The shorter way round from angle b to angle a, between -PI and PI.
float AngleDiff(float a, float b)
{
    return (frac((a - b) / UNITY_TWO_PI + 0.5) - 0.5) * UNITY_TWO_PI;
}

One small trap when you copy this is fmod. It looks like it does the same job, but fmod(-0.3, 1) is −0.3, so wrapping with fmod breaks for negative angles. Keep frac.

Credits

The polar heart and its heartbeat are from Inigo Quilez’s 2D heart shader on Shadertoy. The heart, box and circle distances in the ticks demo are from his 2D distance functions.

Takeaways

  • Radius is ∣p∣|p|, the distance from the centre. Angle is atan(y, x), and it jumps by 2π2\pi on the left (that’s the seam).
  • fract of the radius gives rings, of the angle gives rays, and of both added gives spirals. Whole counts around the circle hide the seam.
  • To repeat a shape round a circle, turn each pixel back into the first slice and draw the shape once. That’s the whole trick.
Search the lessons

Type any word from the lessons, like normalize or smoothstep. The start of a word is enough.