PL. 85 · FIELDS / TILING / REFLECTION GROUP
Hyperbolic Tiling {p,q}
Eugenio Beltrami, 1868 · Henri Poincaré, 1882 · H. S. M. Coxeter, 1957
OPEN THE LIVE PLATE ▸DEFINITION
{p,q} exists in the disk ⟺ (p−2)(q−2) > 4
qₘᵢₙ(p) = ⌊4/(p−2)⌋ + 3, the slider sets q − qₘᵢₙ
R² = cos(π/p + π/q) / cos(π/p − π/q)
edge mirror: centre c = (R²+1)/(2R cos(π/p)), radius √(c²−1)
NOTES
The whole hyperbolic plane fits inside a circle if you agree that distance grows without bound as you approach the rim. Then regular polygons can tile it in ways the flat plane forbids — five squares around a vertex, or seven triangles — because the angles no longer have to add to a full turn; Gauss–Bonnet only asks that (p−2)(q−2) exceed 4. This plate finds the tile a pixel belongs to by folding rather than by building: reflect the point into one wedge, invert it back through the arc that bounds the central polygon, repeat. The count of reflections is all the colour it needs, because parity two-colours the fundamental triangles, and that is precisely the figure Coxeter published in 1957 and posted to Escher, who wrote back that it had shown him how to make a repeating pattern shrink to a limit. The tiling is infinite and the pixel grid is not: where the folds run out the plate goes to ink, and how many are needed depends on {p,q} rather than on resolution — measured, {6,4} needs 1.5 on average and {3,7} needs 4.5. The disk isometries doing the folding are Möbius transformations — the same group plate 137 projects a sphere through and whose discrete subgroup permutes the circles of plate 54.
PROVENANCE
- Origin
- The conformal disk model is Beltrami’s — E. Beltrami, "Teoria fondamentale degli spazii di curvatura costante", Annali di Matematica (Ser. II) 2, 1868, pp. 232–255. It is universally called the Poincaré disk because H. Poincaré, "Théorie des groupes fuchsiens", Acta Mathematica 1, 1882, pp. 1–62, made it the working setting for Fuchsian groups. Both names are on this plate; the attribution in the common one is wrong.
- Figure
- The {p,q} notation is Schläfli’s. The reflection-parity colouring is H. S. M. Coxeter, "Crystal symmetry and its generalizations", Transactions of the Royal Society of Canada 51, 1957 — his figure was the (6,4,2) triangle group, this plate’s default, and it is what Escher credited for the Circle Limit series in 1958.
- Standing
- Public domain — 19th-century geometry
- Constants
- q is set as an offset above ⌊4/(p−2)⌋+3, the smallest degree Gauss–Bonnet allows for that p. Declared as a plain 3..12 pair instead, eight of the hundred (p, q) positions are not hyperbolic and clamp onto a neighbour — a slider position that repeats the one before it, which is the same defect as a slider that does nothing. Offsetting makes every position a different tiling, and it is what keeps {3,7}, the Hurwitz bound, on the dial at all. Fold depth is the cost governor and is locked.
TOUCHDESIGNER · GLSL
The same shader this plate runs, reframed for a GLSL TOP. Pasted bare it renders the published constants as a still frame; wire absTime.seconds into u_t on the Vectors page to animate it.
// FORMA — PL. 85 · HYPERBOLIC TILING {P,Q} — Eugenio Beltrami, 1868 · Henri Poincaré, 1882 · H. S. M. Coxeter, 1957
// {p,q} exists in the disk ⟺ (p−2)(q−2) > 4
// qₘᵢₙ(p) = ⌊4/(p−2)⌋ + 3, the slider sets q − qₘᵢₙ
// R² = cos(π/p + π/q) / cos(π/p − π/q)
// edge mirror: centre c = (R²+1)/(2R cos(π/p)), radius √(c²−1)
// TouchDesigner port — paste into a GLSL TOP's pixel shader. Set the
// resolution on the TOP's Common page. As pasted it renders the published
// constants as a still frame; to animate, add a uniform named u_t on the
// GLSL TOP's Vectors 1 page with the expression absTime.seconds.
// Constants are consts — edit to tweak; comments give the measured range.
// Written from the published mathematics, not adapted from any code.
#define u_res (uTDOutputInfo.res.zw)
uniform float u_t; // absTime.seconds on the Vectors page; unset = still
const float u_phase = 0.8433; // this plate's own grid phase, 0..1
// FORMA's FIELDS accent as cosine-gradient coefficients
const vec3 u_pal_a = vec3(0.46, 0.3031, 0.11);
const vec3 u_pal_b = vec3(0.5, 0.3294, 0.1196);
const vec3 u_pal_c = vec3(1, 1, 1);
const vec3 u_pal_d = vec3(0, 0.05, 0.1);
const float p_p = 6.0; // p — sides per polygon · live 3 .. 12
const float p_qx = 0.0; // q above the hyperbolic minimum · live 0 .. 6
const float p_depth = 14.0; // fold depth · live 4 .. 24
const float p_glide = 0.14; // hyperbolic drift · live 0.02 .. 0.5
const float p_fade = 0.45; // fade with depth · live 0 .. 1
/* The order's ramp — the same cosine formulation the JS kit uses, so a
plate keeps its classification colour in either language. */
vec3 ramp(float t){
return clamp(u_pal_a + u_pal_b * cos(6.28318530718 * (u_pal_c * t + u_pal_d)), 0.0, 1.0);
}
/* Sawtooth and triangle on this plate's phase, mirroring the JS kit. */
float cycle(float t, float period){ return fract(t / period + u_phase); }
float pingpong(float t, float period){
float u = cycle(t, period);
return u < 0.5 ? u * 2.0 : 2.0 - u * 2.0;
}
vec3 plate(vec2 uv){
float PI = 3.141592653589793, TAU = 6.283185307179586;
vec3 ink = vec3(4.0, 6.0, 10.0) / 255.0;
float P = floor(p_p + 0.5);
/* Gauss-Bonnet: (p-2)(q-2) > 4 or the figure is not a disk tiling, so the
slider counts up from the smallest q that satisfies it. */
float Q = floor(4.0 / (P - 2.0)) + 3.0 + floor(p_qx + 0.5);
float R2 = cos(PI / P + PI / Q) / cos(PI / P - PI / Q);
float R = sqrt(R2);
float cc = (R2 + 1.0) / (2.0 * R * cos(PI / P));
float cr2 = cc * cc - 1.0, cr = sqrt(cr2);
float wedge = PI / P, span = 2.0 * wedge;
int depth = int(floor(p_depth + 0.5));
float rho = pingpong(u_t, 34.0) * p_glide * 5.0;
float th = u_phase * TAU, ta = tanh(rho * 0.5);
float ax = ta * cos(th), ay = ta * sin(th);
float ar = u_res.y / u_res.x, fit = min(1.0, ar);
float x = (uv.x - 0.5) * 2.0 / fit;
float y = (uv.y - 0.5) * 2.0 * ar / fit;
if (x * x + y * y >= 1.0) return ink;
float nx = x + ax, ny = y + ay;
float dx = 1.0 + ax * x + ay * y, dy = ax * y - ay * x, dd = dx * dx + dy * dy;
x = (nx * dx + ny * dy) / dd;
y = (ny * dx - nx * dy) / dd;
int refl = 0, inv = 0;
bool settled = false;
for (int i = 0; i < 24; i++){ // 24 is the depth slider's own ceiling
if (i >= depth) break;
float a = atan(y, x), m = sqrt(x * x + y * y);
float am = a - floor(a / span) * span;
if (am > wedge){ am = span - am; refl++; }
x = m * cos(am); y = m * sin(am);
float ex = x - cc, q2 = ex * ex + y * y;
if (q2 < cr2){
float s = cr2 / q2;
x = cc + ex * s; y *= s;
refl++; inv++;
} else { settled = true; break; }
}
if (!settled) return ink;
/* Parity is Coxeter's two-colouring; depth inside the polygon draws the tile
edges as dark seams across both classes. Parity alone is a nine-level
plate, which is an outlier against everything around it. */
float edge = min(1.0, (sqrt((x - cc) * (x - cc) + y * y) - cr) / (cc - cr));
float dim = 1.0 - p_fade * min(1.0, float(inv) / 9.0);
return (refl - (refl / 2) * 2) == 1 ? ramp(0.50 + 0.16 * edge * dim)
: ramp(0.50 + (0.28 + 0.28 * edge) * dim);
}
out vec4 fragColor;
void main(){
// FORMA's uv runs y-down, matching its canvas; TD's vUV runs up
vec2 uv = vec2(vUV.s, 1.0 - vUV.t);
fragColor = TDOutputSwizzle(vec4(plate(uv), 1.0));
}
NUKE · BLINKSCRIPT
The same shader this plate runs, transpiled to a BlinkScript kernel. Paste it into a BlinkScript node's Kernel Source and press Recompile; every constant arrives as a knob at its published value, and u_t animates with the expression frame/24. Compiled and rendered in Nuke 17.1, then compared against this plate on the page.
// FORMA — PL. 85 · HYPERBOLIC TILING {P,Q} — Eugenio Beltrami, 1868 · Henri Poincaré, 1882 · H. S. M. Coxeter, 1957
// {p,q} exists in the disk ⟺ (p−2)(q−2) > 4
// qₘᵢₙ(p) = ⌊4/(p−2)⌋ + 3, the slider sets q − qₘᵢₙ
// R² = cos(π/p + π/q) / cos(π/p − π/q)
// edge mirror: centre c = (R²+1)/(2R cos(π/p)), radius √(c²−1)
// Nuke port — a BlinkScript kernel. Paste into a BlinkScript node's Kernel
// Source and press Recompile. Every constant arrives as a knob at its published
// value (the comment gives the measured range); u_t is a knob too — animate it
// with the expression frame/24 or leave it at 0 for the still frame. Written
// from the published mathematics, not adapted from any code.
// Transpiled from the shader this plate runs on the page (GLSL ES 3.00):
// vec → float2/3/4, swizzles expanded, GLSL builtins Blink lacks written out
// as forma_ functions, float literals suffixed. Compiled and rendered in a
// real Nuke (17.1v1) and compared against this plate on the page: 34 of 34.
//
// plate() and its helpers are written to a single exit — the loop that runs
// once. That is not a style: Blink 17.1 drops a conditional early return from
// a called function while Vectorize is on, which is the node default, with no
// warning and no error. Written this way it paints correctly as pasted.
kernel Forma_hyperbolic : ImageComputationKernel<ePixelWise>
{
Image<eWrite> dst;
param:
float u_t; // seconds; 0 is the still frame
float p_p; // p — sides per polygon · live 3 .. 12
float p_qx; // q above the hyperbolic minimum · live 0 .. 6
float p_depth; // fold depth · live 4 .. 24
float p_glide; // hyperbolic drift · live 0.02 .. 0.5
float p_fade; // fade with depth · live 0 .. 1
local:
float2 u_res;
float u_phase;
float3 u_pal_a, u_pal_b, u_pal_c, u_pal_d;
void define(){
defineParam(u_t, "u_t", 0.0f);
defineParam(p_p, "p_p", 6.0f);
defineParam(p_qx, "p_qx", 0.0f);
defineParam(p_depth, "p_depth", 14.0f);
defineParam(p_glide, "p_glide", 0.14f);
defineParam(p_fade, "p_fade", 0.45f);
}
void init(){
u_res = float2(float(dst.bounds.width()), float(dst.bounds.height()));
u_phase = 0.8433f; // this plate's own grid phase, 0..1
// FORMA's FIELDS accent as cosine-gradient coefficients
u_pal_a = float3(0.46f, 0.3031f, 0.11f);
u_pal_b = float3(0.5f, 0.3294f, 0.1196f);
u_pal_c = float3(1.0f, 1.0f, 1.0f);
u_pal_d = float3(0.0f, 0.05f, 0.1f);
}
/* GLSL builtins Blink lacks, written as templates rather than overload sets.
Blink's operators return expression templates (Swizzle<float,N>), so a call
passing an expression cannot resolve against an overload set on float2
against float3 — measured in Nuke 17.1: a float2 expression is ambiguous
between the two, while scalar-against-vector resolves. A template deduces
the expression's own type, so the ambiguity cannot arise. */
template <class T> T forma_fract(T v){ return v - floor(v); }
template <class T, class S> T forma_mod(T x, S y){ return x - y * floor(x / y); }
/* Blink's own min/max/clamp take no scalar bound against a vector, which GLSL
does; v * 0.0f + b is that bound at the vector's own width, and collapses to
b when v is a scalar, so one template serves both. */
template <class T, class S> T forma_min(T a, S b){ return min(a, a * 0.0f + b); }
template <class T, class S> T forma_max(T a, S b){ return max(a, a * 0.0f + b); }
template <class T, class S> T forma_clamp(T v, S lo, S hi){ return clamp(v, v * 0.0f + lo, v * 0.0f + hi); }
int forma_min(int a, int b){ return min(a, b); }
int forma_max(int a, int b){ return max(a, b); }
/* GLSL step(edge, x) is 1 where x >= edge; floor(sign(x - e) * 0.5 + 1) is
that exactly, equality included, out of builtins Blink does have. */
template <class T, class S> T forma_step(S e, T x){ return floor(sign(x - e) * 0.5f + 1.0f); }
template <class T, class S> T forma_smoothstep(S a, S b, T x){
T t = forma_clamp((x - a) / (b - a), 0.0f, 1.0f);
return t * t * (3.0f - 2.0f * t);
}
template <class T> float forma_distance(T a, T b){ return length(a - b); }
float forma_tanh(float x){ float e = exp(2.0f * x); return (e - 1.0f) / (e + 1.0f); }
float forma_radians(float d){ return d * 0.01745329252f; }
// the page's hash2 is exact uint32; Blink has int, so the shifts are made
// logical by masking and the read-back is lifted into 0 .. 2^32
/* A uint read back as a float. Blink has no unsigned type, so a value past
2^31 arrives as a negative int and float() of it is negative. Measured on
gabor, whose own generator then returned uniforms in [-0.5, 0.5) and drew
a different picture — it compiled, it rendered, and only comparing it with
/* The order's ramp — the same cosine formulation the JS kit uses, so a
plate keeps its classification colour in either language. */
float3 ramp(float t){
return forma_clamp(u_pal_a + u_pal_b * cos(6.28318530718f * (u_pal_c * t + u_pal_d)), 0.0f, 1.0f);
}
/* Sawtooth and triangle on this plate's phase, mirroring the JS kit. */
float cycle(float t, float period){ return forma_fract(t / period + u_phase); }
float pingpong(float t, float period){
float u = cycle(t, period);
return u < 0.5f ? u * 2.0f : 2.0f - u * 2.0f;
}
float3 plate(float2 uv){
float3 forma_r = float3(0.0f, 0.0f, 0.0f);
for (int forma_once = 0; forma_once < 1; forma_once++){
float forma_PI = 3.141592653589793f;
float TAU = 6.283185307179586f;
float3 ink = float3(4.0f, 6.0f, 10.0f) / 255.0f;
float P = floor(p_p + 0.5f);
/* Gauss-Bonnet: (p-2)(q-2) > 4 or the figure is not a disk tiling, so the
slider counts up from the smallest q that satisfies it. */
float Q = floor(4.0f / (P - 2.0f)) + 3.0f + floor(p_qx + 0.5f);
float R2 = cos(forma_PI / P + forma_PI / Q) / cos(forma_PI / P - forma_PI / Q);
float R = sqrt(R2);
float cc = (R2 + 1.0f) / (2.0f * R * cos(forma_PI / P));
float cr2 = cc * cc - 1.0f;
float cr = sqrt(cr2);
float wedge = forma_PI / P;
float span = 2.0f * wedge;
int depth = int(floor(p_depth + 0.5f));
float rho = pingpong(u_t, 34.0f) * p_glide * 5.0f;
float th = u_phase * TAU;
float ta = forma_tanh(rho * 0.5f);
float ax = ta * cos(th);
float ay = ta * sin(th);
float ar = u_res.y / u_res.x;
float fit = forma_min(1.0f, ar);
float x = (uv.x - 0.5f) * 2.0f / fit;
float y = (uv.y - 0.5f) * 2.0f * ar / fit;
if (x * x + y * y >= 1.0f) { forma_r = ink; break; }
float nx = x + ax;
float ny = y + ay;
float dx = 1.0f + ax * x + ay * y;
float dy = ax * y - ay * x;
float dd = dx * dx + dy * dy;
x = (nx * dx + ny * dy) / dd;
y = (ny * dx - nx * dy) / dd;
int refl = 0;
int inv = 0;
bool settled = false;
for (int i = 0; i < 24; i++){ // 24 is the depth slider's own ceiling
if (i >= depth) break;
float a = atan2(y, x);
float m = sqrt(x * x + y * y);
float am = a - floor(a / span) * span;
if (am > wedge){ am = span - am; refl++; }
x = m * cos(am); y = m * sin(am);
float ex = x - cc;
float q2 = ex * ex + y * y;
if (q2 < cr2){
float s = cr2 / q2;
x = cc + ex * s; y *= s;
refl++; inv++;
} else { settled = true; break; }
}
if (!settled) { forma_r = ink; break; }
/* Parity is Coxeter's two-colouring; depth inside the polygon draws the tile
edges as dark seams across both classes. Parity alone is a nine-level
plate, which is an outlier against everything around it. */
float edge = forma_min(1.0f, (sqrt((x - cc) * (x - cc) + y * y) - cr) / (cc - cr));
float dim = 1.0f - p_fade * forma_min(1.0f, float(inv) / 9.0f);
{ forma_r = (refl - (refl / 2) * 2) == 1 ? ramp(0.50f + 0.16f * edge * dim)
: ramp(0.50f + (0.28f + 0.28f * edge) * dim); break; }
}
return forma_r;
}
void process(int2 pos){
// FORMA's uv runs y-down like its canvas; Nuke's rows run up
float2 uv = float2((float(pos.x) + 0.5f) / u_res.x, 1.0f - (float(pos.y) + 0.5f) / u_res.y);
float3 c = plate(uv);
dst() = float4(c.x, c.y, c.z, 1.0f);
}
};