Rendering The Droste Effect
The Droste effect is a recursive image where a smaller version of the image appears within itself. While it often looks like a simple zoom, the most fascinating versions involve a “twist” or spiral. Probably the most famous is M. C. Escher’s “Print Gallery”.
I have always thought it would be fun to create these kinds of images myself, and being a graphics guy, why not write a WebGL shader to do this? There are a variety of tutorials and even software products that can manipulate your images, but creating these images in realtime is a fun challenge.
(TL;DR: Just show me the pictures!)
I attempted to follow the explanation from that Leiden University site to render the Droste effect via WebGL in my earlier 2014 Droste Effect Experiment post. While I did get it “working”, I can’t say I really understood it. Finally, after watching this excellent 3Blue1Brown video, I believe I understand the math well enough to (yes, with some AI help) create an interactive WebGL implementation.
The thing that makes it conceptually difficult is, first, this is a complicated enough concept that it takes 45 minutes for an expert to explain it in four separate steps involving exponentials & logarithms. Then, in order to apply this in a WebGL context inside your browser, you need to reverse those steps and also project this onto a texture image with 0-1 coordinates.
This post relies completely on concepts from the video and I’m not going to attempt to re-explain them. So, go watch the video if you haven’t. This post won’t make much sense without watching.
Mathematical Summary
First, we will summarize the steps as presented in the video. We’ll call this The Forward Pipeline.
1. The Forward Pipeline
In the video, we think of pushing points forward from the input texture (see 1. Input Image below) through the cropped complex plane $z$ (see 2. Selected Image below) to the final Droste image $z’$ (see 5. Final Droste Image below):
Preliminaries:
- We have an outer selected circle of radius $R$ centered at $u_{\text{center}}$ in the normalized UV coordinates $[0, 1]^2$ of 1. Input Image.
- Let $Z$ be the Droste zoom factor ($1.1 \le Z \le 32$). In the video, Grant used 16. $L = \ln(Z)$ will be the logarithmic width of one zoom cycle.
- We have an inner selected circle that has a radius of $R / Z$ and is also centered at $u_{\text{center}}$ in 1. Input Image.
- Inside the annulus between the inner selected circle and outer selected circle we choose a fixed point $z_0$ as a pivot point.
-
Crop to the $z$-plane (see 2. Selected Image): Shift and scale the selected region $z = \frac{\text{uv} - u_{\text{center}}}{R}\pi$ so the outer selected circle maps to the complex disk $\lvert z \rvert \le \pi$ inside $[-\pi, \pi]^2$. The annulus between the inner selected circle and outer selected circle is $\frac{\pi}{Z} \le \lvert z \rvert \le \pi$.
-
Complex Logarithm ($z \mapsto w$) (see 3. Log-polar Map): Unroll the annulus into a vertical strip in the log-polar plane:
\[w = \ln(z) = \ln\lvert z \rvert + i\arg(z), \quad w_0 = \ln(z_0)\]Tiling the annulus infinitely in all directions repeats this strip horizontally every $L$ and vertically every $2\pi$.
-
Twist Transformation ($w \mapsto w’$) (see 4. Twisted Map): Rotate and scale the tiled $w$-plane around the fixed point $w_0$ so that the diagonal period vector $L + 2\pi i$ is mapped onto the vertical period vector $2\pi i$:
\[w' = c \cdot (w - w_0) + w_0, \quad \text{where } c = \frac{2\pi i}{L + 2\pi i}\] -
Complex Exponential ($w’ \mapsto z’$) (see 5. Final Droste Image): Wrap the twisted strip back into the complex plane:
\[z' = e^{w'} = e^{\text{Re}(w')}\left(\cos(\text{Im}(w')) + i\sin(\text{Im}(w'))\right)\]Because $w’$ now repeats vertically with period $2\pi i$ across adjacent zoom strips, $e^{w’}$ connects them into a continuous, seamless spiral.
2. The Reverse Pipeline (Fragment Shader Perspective)
In WebGL, a fragment shader runs per output pixel and must work in reverse — starting from a destination pixel coordinate and working backward to look up a color in the input texture. Note that each of the images displayed below goes through only its portion of this pipeline. E.g., the final image goes through all these stages 1-5, but the prior image just goes through 3-5.
-
Screen Pixel to Complex $z’$: Map the normalized fragment coordinate $\text{uv} \in [0, 1]^2$ to $[-\pi, \pi]^2$:
\[z' = (\text{uv} \cdot 2 - 1)\pi\] -
Unroll to the Twisted Plane ($z’ \mapsto w’$): Apply the complex logarithm to find where this output pixel sits in the twisted plane:
\[w' = \ln(z') = \ln\lvert z' \rvert + i\operatorname{atan2}(z'_y, z'_x)\] -
Inverse Twist ($w’ \mapsto w$): Invert the twist transformation by dividing by $c$ around $w_0 = \ln(z_0)$ to recover the untwisted log-polar coordinate $w$:
\[w = \frac{w' - w_0}{c} + w_0, \quad \text{where } c = \frac{2\pi i}{L + 2\pi i}\] -
Periodic Tiling ($\bmod$ into the Fundamental Annulus): Wrap $w$ back into the single source strip $\text{Re}(w) \in [\ln(\pi) - L, \ln(\pi)]$, $\text{Im}(w) \in [-\pi, \pi]$:
\[w_x \leftarrow \operatorname{mod}(w_x - \ln\pi,\; L) + \ln\pi - L\] \[w_y \leftarrow \operatorname{mod}(w_y + \pi,\; 2\pi) - \pi\] -
Map Back to Input Texture ($w \mapsto z \mapsto \text{uv}_{\text{sample}}$): Exponentiate $w$ back to the cropped $z$-plane and scale into the input texture’s UV space:
\[z = e^w, \quad \text{uv}_{\text{sample}} = u_{\text{center}} + \frac{z}{\pi} \cdot R\]
The Reverse Fragment Shader
Those 5 steps become this fragment shader (see droste_panels.frag for the full code):
// vUv is the normalized fragment coordinate in [0, 1]^2
// Step 1: Screen Pixel to Complex z'
vec2 zPrime = (vUv * 2.0 - 1.0) * PI;
// Step 2: Unroll to Twisted Plane (z' -> w')
vec2 wPrime = clog(zPrime);
// Step 3: Inverse Twist (w' -> w)
vec2 c = cdiv(vec2(0.0, TAU), vec2(L, TAU));
vec2 w = cdiv(wPrime - w0, c) + w0;
// Step 4: Periodic Tiling (mod into the Fundamental Annulus)
w.x = mod(w.x - log(PI), L) + log(PI) - L;
w.y = mod(w.y + PI, TAU) - PI;
// Step 5: Map Back to Input Texture (w -> z -> sampleUV)
vec2 z = cexp(w);
vec2 sampleUV = uCenterUV + (z / PI) * uSelectionRadius;
gl_FragColor = texture2D(tDiffuse, sampleUV);
The Result
Below are five panels showing the transformation process. 2-5 are in the order specified in the YouTube video. I added panel 1 to show an original image that we can select within.
- Select the input image from the dropdown or upload your own image. Best results with square images.
- Within the 1. Input Image, use the following controls to select the portion used:
- adjust the orange circular region that we can read,
- the orange point is the center, which you can move around, and
- drag the dotted orange circle to adjust the Droste Zoom factor (between 1.1x and 32x).
- Within the 2. Selected Image: Click and drag the interactive green point ($z_0$) here to choose the fixed point for the twist. The orange spiral arrow winds one full $360^\circ$ turn inward from $z_0$ to $z_0 / Z$.
- Watch the results move through the pipeline. In 3. Log-polar Map, that orange spiral arrow unrolls into a straight diagonal orange vector from the green point ($w_0$) (flipped between $\pm(L + 2\pi i)$ to stay in view), which 4. Twisted Map rotates and scales into a vertical orange vector of length $2\pi$ to produce 5. Final Droste Image.