Skip to content
Sample of Poincare-disk hyperbolic tiling (Escher / Coxeter)

fractals_3d_webgl · HyperbolicTilingPoincare3D

Poincare-disk hyperbolic tiling (Escher / Coxeter)

A 3D hyperboloid-lifted rendering of the (p, q) hyperbolic tessellation in the Poincare disk model. Points are sampled uniformly in the unit disk, reflected into the fundamental (p, q, 2) triangle via the coset expansion of the triangle group, then lifted to 3D via the hyperboloid model (x, y, z) = (2x, 2y, 1 + r^2) / (1 - r^2). The 3D cloud is orthographically projected from a slow camera orbit and splatted into a decaying frame buffer with a tile-index / depth driven warm-to-cool palette. The classic Escher Circle Limit cases {6,4} (I), {6,3} (II/III), {4,3} (IV) are all reachable by tuning p and q.

Original: https://en.wikipedia.org/wiki/Poincar%C3%A9_disk_model

▶ Try the live demo (JS port) ▶ Run reference.py via Pyodide 10 formulas have instant JS ports. The Pyodide path runs the actual Python in your browser — first load is ~6 MB, then instant.

Family
fractals_3d_webgl
Source
fractals_3d_webgl
Evolution
fractal
Render
point_cloud
Complexity
high
Agents
18000
Year
1958
Author
M. C. Escher / H. S. M. Coxeter (1958 Circle Limit I)

Math essence

Poincare disk: ds^2 = 4|dz|^2 / (1-|z|^2)^2. The (p, q) tiling is generated by R = rotation by 2*pi/p and S = reflection across the line through the origin at angle theta = pi/p + pi/q - pi/2; S(z) = exp(2i*theta) * conj(z). For each sampled point z, a coset expansion iterates S R^k (up to 2p steps) until the point lands in the fundamental triangle, tracking the tile index. Hyperboloid lift: (x, y, z) = (2x, 2y, 1 + x^2 + y^2) / (1 - x^2 - y^2) embeds H^2 in R^3. Project orthographically with pitch/yaw and splat into a frame buffer.

Parameters

Name Type Default Range Description
dim int 256 128–512 Square canvas side length in pixels.
p int 6 3–12 Number of tiles around each vertex (p-symmetric). 6 = Escher Circle Limit I {6,4}.
q int 4 2–8 Number of tiles meeting at each polygon edge (q-gons). 4 = Escher Circle Limit I.
num_points int 18000 2000–60000 Number of disk-uniform points.
camera_pitch float 0.3 -1.2–1.2 Camera pitch (radians) over the hyperboloid.
camera_yaw float 0.25 -1.2–1.2 Camera yaw (radians) over the hyperboloid.
orbit_speed float 0.08 0–0.4 Per-step camera phase increment.
splat_radius float 0.45 0–3 Soft splat radius (px).
decay float 0.96 0.7–0.999 Per-step canvas fade.
palette_warm tuple 255,220,150 — Palette warm end (near the boundary / shallow z).
palette_cool tuple 140,90,220 — Palette cool end (near the centre / tall z).
background tuple 10,12,26 — Background colour.
seed int 7 0–999 RNG seed.

Reference

First 60 lines of reference.py. Full source: library_external/fractals_3d_webgl/HyperbolicTilingPoincare3D/reference.py

from __future__ import annotations

from typing import ClassVar

import numpy as np


def _mobius_add(z: np.ndarray, a: np.ndarray) -> np.ndarray:
    """Mobius (hyperbolic) addition in the open unit disk: z (+) a.

    z and a are complex 1D arrays of the same shape.  The result is
    also a complex array; |z (+)| < 1 whenever |z| < 1 and |a| < 1.
    This is the standard Poincare-disk addition used in Escher's
    Circle Limit etchings and in the M.C. Escher / H.S.M. Coxeter
    correspondence on hyperbolic tessellations.
    """
    za = z * a
    num = z + a
    den = 1.0 + za.conj()
    return num / den


def _tile_reflect(z: np.ndarray, p: int, q: int) -> np.ndarray:
    """Reflect z (a complex array, |z|<1) across one side of the
    (p, q) hyperbolic triangle with one vertex at the origin.

    The triangle has interior angle pi/p at the origin and pi/q at
    each of the two finite vertices on the unit circle.  The
    generators of the (p, q, 2) triangle group are
        R = rotation by 2*pi/p
        S = inversion across the line through 0 and e^{i theta}
    with theta = pi/p + pi/q - pi/2 (so the third angle is pi/2 at
    the finite vertices).  Inversion across a line through the origin
    is just complex conjugation after rotation:
        S(z) = exp(2 i theta) * conj(z)
    A "coset expansion" tile: reflect z against S, then again against
    S R^k until we find a representative inside the fundamental
    triangle; report how many reflections it took.
    """
    theta = np.pi / p + np.pi / q - np.pi / 2.0
    e2i = np.exp(2j * theta)
    n_reflect = np.zeros(z.shape, dtype=np.int32)
    # Naive coset expansion: up to 2*p iterations
    max_iter = 2 * p
    inside = np.ones(z.shape, dtype=bool)
    for k in range(max_iter):
        if not np.any(inside):
            break
        z_work = z[inside]
        rot = np.exp(2j * np.pi * k / p)
        z2 = e2i * np.conj(z_work)  # S
        z3 = z2 * rot  # then rotate
        # Are we inside the wedge [0, 2*pi/p] with theta < arg < pi-theta?
        arg = np.angle(z3)
        ok = (arg >= -theta) & (arg <= 2 * np.pi / p + theta)
        if np.any(ok):
            z[inside] = np.where(ok, z3, z[inside])
            n_reflect[inside] += np.where(ok, 1, 0)
            inside[inside] = ~ok
    return n_reflect

Try running it locally

The reference implementation is self-contained Python with numpy. Install the package and run:

python library_external/fractals_3d_webgl/HyperbolicTilingPoincare3D/reference.py

Need help? See Learn for the standard environment setup, or Playground for an in-browser sketchbook.

More from fractals_3d_webgl

See all fractals_3d_webgl artworks →