Animating Right-Angled Hyperbolic Pentagons

Turning the disk realization into a shader that folds each pixel back into the pentagon.

The last note gave an explicit description of the right angled pentagon in the Poincaré disk: five geodesics, each named by the center of the Euclidean circle carrying it. Reflections in that model are just as explicit — reflecting in a geodesic is inversion in its circle, which is one line of arithmetic.

Put those two together and the tiling draws itself. It is the orbit of the pentagon under the group generated by those five inversions:

That picture is about fifty lines of GLSL. Rather than drop it on you whole, here is each idea on its own.

A geodesic is a point

In the disk, a geodesic is an arc of a circle meeting the boundary at right angles. Two circles are orthogonal when the distance between their centers satisfies C1C22=r12+r22|C_1-C_2|^2=r_1^2+r_2^2, and the boundary is the circle of radius 11 about 00, so a geodesic’s circle has

C2=1+r2|C|^2=1+r^2

The radius is not extra information: it is determined by the center. A geodesic is just a point of the plane lying outside the unit disk — the further out, the flatter the arc, with diameters as the limiting case.

Drag the center below and watch the arc follow it: far out, the geodesic flattens towards a diameter; brought in close to the boundary, it shrinks away. The two crossings stay square throughout, because that is the only condition being imposed.

We only ever need r2r^2, so nothing here needs a square root:

float radius2(vec2 C){ return dot(C, C) - 1.0; }

Reflection is inversion

Reflecting the hyperbolic plane in a geodesic is, in this model, inversion in the circle carrying it: a point at Euclidean distance ρ\rho from the center, along some ray, goes to the point at distance r2/ρr^2/\rho along the same ray.

vec2 reflectIn(vec2 p, vec2 C){
    vec2 d = p - C;
    return C + radius2(C) * d / dot(d, d);
}

Read the last line as a sentence: d points from the center to p; dividing by dot(d, d) turns it into a vector of length 1/ρ1/\rho in the same direction; multiplying by r2r^2 makes it r2/ρr^2/\rho.

Drag either the mirror’s center or the shape. Whatever you do the image stays inside the disk — inversion in a circle orthogonal to the boundary preserves it — and points sitting on the mirror do not move at all.

Telling which side of the geodesic a point is on is even cheaper — inside the circle, or outside it:

bool inside(vec2 p, vec2 C){
    vec2 d = p - C;
    return dot(d, d) < radius2(C);
}

Building the pentagon

Two of the five sides pass through the center of the disk, so they are diameters; put them on the axes. The other three are circles, and the theorem from the last note gives their centers outright:

void pentagonSides(out vec2 C[3]){
    C[0] = vec2(1.0 / tanh(a), 0.0);     // gamma_e
    C[1] = vec2(0.0, 1.0 / tanh(b));     // gamma_c
    C[2] = vec2(tanh(a), tanh(b));       // gamma_d
}

Three lines, and the whole moduli space is in them. It is worth pausing on the last one: its radius squared is

tanh2a+tanh2b1=sinh2asinh2b1cosh2acosh2b\tanh^2 a+\tanh^2 b-1=\frac{\sinh^2a\,\sinh^2b-1}{\cosh^2a\,\cosh^2b}

which is positive exactly when sinhasinhb>1\sinh a\sinh b>1. So the condition that a pentagon exists at all is not something the program has to be told — it is the question of whether radius2(C[2]) came out positive. Push the sliders below the threshold and the figure empties, because the fifth side stops being a real circle.

This function is also the only part of the program that knows it is drawing a pentagon. Everything after it works for any right angled polygon, given a different list of centers.

Folding into the domain

Here is the idea that avoids ever building a tiling. The tiles are the images of one pentagon under the reflection group, so instead of generating them, take the pixel backwards: while it is on the wrong side of some wall, reflect it there. Each reflection moves it into a tile nearer the original, so the process stops — and the number of reflections it took is how deep in the group the pixel’s tile sits.

The pentagon lies in the positive quadrant and outside all three circles, so “the wrong side” is a negative coordinate or the inside of a circle. Reflecting in the two diameters is a sign flip, which is why they cost nothing:

int fold(inout vec2 p, vec2 C[3]){
    int word = 0;
    for (int step = 0; step < 48; step++){
        bool moved = false;
        if (p.x < 0.0){ p.x = -p.x; word++; moved = true; }
        if (p.y < 0.0){ p.y = -p.y; word++; moved = true; }
        for (int k = 0; k < 3; k++){
            if (inside(p, C[k])){ p = reflectIn(p, C[k]); word++; moved = true; }
        }
        if (!moved) break;
    }
    return word;
}

The step limit is not a safety net but a fact about the picture: approaching the ideal boundary the word length grows without bound, and at some point a pixel simply cannot be resolved. Raising the cap buys detail near the edge and costs time everywhere.

Drawing walls

To ink the edges we need a distance from a point to a geodesic, and there is a clean one. For the circle with center CC,

sinhd(p,γ)=pC2r2r(1p2)\sinh d(p,\gamma)=\frac{\bigl|\,|p-C|^2-r^2\,\bigr|}{r\left(1-|p|^2\right)}

and for a diameter with unit normal nn it is 2pn/(1p2)2|p\cdot n|/(1-|p|^2). Both vanish exactly on the geodesic, and both are genuine hyperbolic distances — so thresholding one draws a line of constant hyperbolic width, which is why the edges thin as they crowd toward the boundary instead of merging into a blur.

float wallDist(vec2 p, vec2 C){
    float r2 = radius2(C);
    vec2 d = p - C;
    return abs(dot(d, d) - r2) / (sqrt(r2) * (1.0 - dot(p, p)));
}

float axisDist(vec2 p, vec2 n){
    return 2.0 * abs(dot(p, n)) / (1.0 - dot(p, p));
}

Putting it together

A fragment maps to a point of the disk, folds home, and reports two numbers: how many reflections that took, and how close it now sits to a wall. The first colors the tile, the second inks the edge.

int word = fold(p, C);

float s = min(axisDist(p, vec2(1.0, 0.0)), axisDist(p, vec2(0.0, 1.0)));
for (int k = 0; k < 3; k++) s = min(s, wallDist(p, C[k]));

vec3 col = mix(uBackground, (word % 2 == 0) ? uBlue : uGold, 0.30);
col = mix(uInk, col, smoothstep(0.0, 2.0 * fwidth(s) + 0.012, s));

The fwidth is the only concession to the screen: it measures how fast s changes from one pixel to the next, so the edge gets exactly one pixel of softening wherever it happens to be.

The whole program

Assembled, with the constants a reader has to supply named at the top. On this page they arrive as uniforms — the two lengths from the sliders, the colors from the site palette — but written as constants it pastes straight into Shadertoy.

// A right-angled hyperbolic pentagon and the tiling it generates, in the
// Poincaré disk. Each piece is worked through in the note this belongs to.
//
// To run on Shadertoy, add these at the top; on the page they are uniforms —
// the two lengths from the moduli plot, the colors from the site palette.
//
//   const float a = 1.05, b = 1.25;
//   const vec3 uBackground = vec3(0.96, 0.95, 0.92);
//   const vec3 uInk  = vec3(0.15, 0.15, 0.16);
//   const vec3 uBlue = vec3(0.20, 0.45, 0.70);
//   const vec3 uGold = vec3(0.80, 0.62, 0.18);

// A geodesic is the center of its circle: orthogonality forces |C|^2 = 1 + r^2.
float radius2(vec2 C){ return dot(C, C) - 1.0; }

vec2 reflectIn(vec2 p, vec2 C){
    vec2 d = p - C;
    return C + radius2(C) * d / dot(d, d);
}

bool inside(vec2 p, vec2 C){
    vec2 d = p - C;
    return dot(d, d) < radius2(C);
}

// sinh of the distance to a geodesic, and to a diameter with unit normal n
float wallDist(vec2 p, vec2 C){
    float r2 = radius2(C);
    vec2 d = p - C;
    return abs(dot(d, d) - r2) / (sqrt(r2) * (1.0 - dot(p, p)));
}

float axisDist(vec2 p, vec2 n){
    return 2.0 * abs(dot(p, n)) / (1.0 - dot(p, p));
}

// gamma_a and gamma_b are the two axes; these are the other three sides.
// radius2(C[2]) is tanh^2(a) + tanh^2(b) - 1: positive exactly when a
// pentagon exists.
void pentagonSides(out vec2 C[3]){
    C[0] = vec2(1.0 / tanh(a), 0.0);     // gamma_e
    C[1] = vec2(0.0, 1.0 / tanh(b));     // gamma_c
    C[2] = vec2(tanh(a), tanh(b));       // gamma_d
}

// Walk a point back into the pentagon, counting reflections. The domain is the
// positive quadrant, outside all three circles.
int fold(inout vec2 p, vec2 C[3]){
    int word = 0;
    for (int step = 0; step < 48; step++){
        bool moved = false;
        if (p.x < 0.0){ p.x = -p.x; word++; moved = true; }
        if (p.y < 0.0){ p.y = -p.y; word++; moved = true; }
        for (int k = 0; k < 3; k++){
            if (inside(p, C[k])){ p = reflectIn(p, C[k]); word++; moved = true; }
        }
        if (!moved) break;
    }
    return word;
}

void mainImage(out vec4 fragColor, in vec2 fragCoord){
    vec2 p = (2.0 * fragCoord - iResolution.xy) / min(iResolution.x, iResolution.y) * 1.04;
    if (dot(p, p) >= 1.0){ fragColor = vec4(uBackground, 1.0); return; }

    vec2 C[3];
    pentagonSides(C);
    if (radius2(C[2]) <= 0.0){ fragColor = vec4(uBackground, 1.0); return; }

    int word = fold(p, C);

    float s = min(axisDist(p, vec2(1.0, 0.0)), axisDist(p, vec2(0.0, 1.0)));
    for (int k = 0; k < 3; k++) s = min(s, wallDist(p, C[k]));

    vec3 col = mix(uBackground, (word % 2 == 0) ? uBlue : uGold, 0.30);
    if (word == 0) col = mix(uBackground, uGold, 0.60);
    col = mix(uInk, col, smoothstep(0.0, 2.0 * fwidth(s) + 0.012, s));

    fragColor = vec4(col, 1.0);
}
← All notes