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 , and the boundary is the circle of radius about , so a geodesic’s circle has
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 , 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 from the center, along some ray, goes to the point at distance 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 in the same direction;
multiplying by makes it .
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
which is positive exactly when . 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 ,
and for a diameter with unit normal it is . 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);
}