Making-of · full technical guide

How the works
were hung

Fractal Atelier treats the Julia set as fine art: curated seeds, art-directed palettes, wall text, and a viewing room for close looking. This page is the studio door left open — every technique, with real code.

← Return to the gallery

01 · Concept

A gallery, not a math demo

Most fractal pages hand you a parameter playground. This site refuses. The curatorial premise is that a Julia set can carry the same weight as a painting if you frame it like one: a fixed crop, a committed palette, a title that asserts a mood, and wall text that talks about the work rather than the formula.

Every design decision follows from that premise. The background is warm gallery umber, not black. Works hang in wooden frames with a brass bezel and an engraved plate. Labels use museum conventions — No. IV, medium, edition. The only concession to interactivity is the museum's own: step closer. Clicking a work opens its viewing room, where the render slowly descends into the spiral.

02 · Stack

One shader, every canvas

The whole site is static HTML/CSS/JS. Rendering is raw WebGL 1 — no Three.js, since a fullscreen triangle and one fragment shader is the entire pipeline. GSAP + ScrollTrigger (CDN, pinned to 3.12.5) drive the scroll choreography. Fonts are Fraunces (display serif) and IBM Plex Mono (labels), from Google Fonts.

A single hidden WebGL canvas renders every image on the page. Each framed work is an ordinary 2D <canvas>; the shared renderer draws into the GL canvas at the target resolution, then blits with drawImage. One GL context serves six frames, the hero, and the viewing room — no context-limit worries, one shader compile.

// the entire render path: size GL canvas, set uniforms, draw, blit
function renderInto(ctx2d, work, view, w, h) {
  glCanvas.width = w; glCanvas.height = h;
  gl.viewport(0, 0, w, h);
  gl.uniform2f(U.uC,      work.c[0], work.c[1]);
  gl.uniform2f(U.uCenter, view.center[0], view.center[1]);
  gl.uniform1f(U.uScale,  view.scale);   // half-height in complex units
  gl.uniform1f(U.uAngle,  view.angle);
  gl.drawArrays(gl.TRIANGLES, 0, 3);     // one fullscreen triangle
  ctx2d.drawImage(glCanvas, 0, 0);       // blit into the frame's 2D canvas
}

js/main.js — shared renderer

03 · The algorithm

Escape-time, smoothed

Every pixel is a complex number z₀. The shader iterates the map and counts how long the orbit takes to escape a large disk. Points that never leave belong to the filled Julia set — they are painted matte, like gallery wall. Points that flee are colored by how reluctantly they fled.

zn+1 = zn² + c

Integer iteration counts band visibly, so the count is smoothed with the standard renormalization — the fractional part measures how far past the escape radius the orbit overshot:

// inside the fragment shader — GLSL
for (int i = 0; i < 420; i++) {
  z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + uC;  // z² + c
  m2 = dot(z, z);
  if (m2 > 4096.0) { n = float(i); break; }        // escaped
}
float sn = n + 1.0 - log2(0.5 * log2(m2));  // smooth iteration count
float t  = uColOff + uColScale * sqrt(sn);  // sqrt compresses the ramp
vec3 col = pal(t);

fragment shader — escape-time core

Two small craft details do a lot of work: a sqrt on the smooth count keeps color cycles from bunching near the boundary, and a hash-based dither (±1/320 per channel) erases the banding that eight-bit output would otherwise show in the slow gradients.

04 · Pigment

Cosine palettes, art-directed

Color comes from Iñigo Quilez's cosine gradient formula — four vec3 coefficients yield an endless, perfectly smooth ramp:

pal(t) = a + b · cos(2π(ct + d))

The curatorial trick: every palette sets its phase d ≈ 0.5 so the ramp starts dark. Far-from-the-set pixels (low iteration) fall into shadow, and the filaments near the boundary carry the light — the fractal reads as a lit object on a dark wall rather than a heat map. Each work owns one palette:

WorkSeed cPalette
The Gilded Whorl−0.7269 + 0.1889i
Prussian Tide0.285 + 0.01i
Vermilion Sermon−0.8 + 0.156i
Dendrite, Winter0 + 1i
Verdigris Chapel−0.4 + 0.6i
Ash & Ember−0.835 − 0.2321i

Interior color is derived automatically — the palette's peak tone multiplied down to about 5% luminance — so each work's "wall" is tinted faintly with its own pigment.

05 · Curation

Choosing where to stand

The seeds are classics of the parameter plane, chosen for distinct temperaments: dense spirals (−0.7269 + 0.1889i), soft estuary curls (0.285 + 0.01i), and one dendrite (c = i) whose Julia set has empty interior — pure boundary, the collection's winter piece.

Each viewing room descends toward the set's repelling fixed point — the solution of z = z² + c with derivative |2z| > 1. This point is guaranteed to lie on the Julia set, and the set is asymptotically self-similar around it, so an infinite zoom lands on an ever-turning spiral rather than empty space. It is computed once per work, in JavaScript, with the complex square root:

function repellingFixedPoint(c) {
  // z = (1 ± √(1−4c)) / 2 — the root with |2z| > 1 lies ON the set
  const wr = 1 - 4*c[0], wi = -4*c[1];
  const r  = Math.hypot(wr, wi);
  const sr = Math.sqrt((r + wr) / 2);
  const si = Math.sign(wi || 1) * Math.sqrt((r - wr) / 2);
  const z1 = [(1 + sr)/2,  si/2], z2 = [(1 - sr)/2, -si/2];
  return Math.hypot(...z1) >= Math.hypot(...z2) ? z1 : z2;
}

js/main.js — the eye of every spiral

06 · The viewing room

Descent mechanics

Zoom must happen in log space — a linear scale change feels like slamming into the floor. The room keeps a target log-scale; the actual scale eases toward it exponentially each frame. Untouched, the room breathes on a 95-second cosine between the hung view and ×3,000 magnification. One wheel tick or drag hands control to the visitor.

// auto descent: log-space ping-pong into the fixed point
const u = 0.5 - 0.5 * Math.cos((t - t0) * (2*Math.PI / 95));
targetLog = Math.log(base) + u * (Math.log(MIN*1.4) - Math.log(base));

// every frame: exponential ease in log space (never linear)
const cur = Math.log(scale);
scale = Math.exp(cur + (targetLog - cur) * Math.min(1, dt * 3.2));

js/main.js — room loop

The floor is scale ≈ 2.6×10⁻⁴. Beyond roughly ×4,000, float32 texture coordinates in the shader run out of mantissa and the spiral dissolves into blocks — the gallery closes the stairwell one flight above the precision cliff. Deeper zooms need double-emulation (splitting each coordinate into two floats), which was judged not worth its cost here.

07 · The atlas

One shader, two families

The "Where the seeds live" section hangs a seventh render: the Mandelbrot set, used as a map of the collection. It costs almost nothing, because the escape-time loop for the Mandelbrot set is the same loop — only the starting conditions differ. A single uMode uniform switches the shader between families with two mix() calls, so no second program is compiled:

// Julia:      the pixel is the orbit's start, c is fixed
// Mandelbrot: the orbit starts at 0, the pixel IS the seed
vec2 pt = uCenter + p * (2.0 * uScale);
vec2 z  = mix(pt, vec2(0.0), uMode);
vec2 cc = mix(uC,  pt,       uMode);

fragment shader — one uniform, both fractals

The six brass pins are absolutely-positioned buttons laid over the canvas. Their positions are computed by running the shader's coordinate mapping in reverse — complex plane to CSS percentage — so they stay glued to their seeds at every viewport width. Each pin's tooltip names its work, and clicking one walks the visitor to that wall with scrollIntoView. The pins carry a slow staggered pulse ring, the gallery's only permission to blink.

08 · The pigment room

Showing the palettes their own portrait

The "Mixing light, not paint" section exhibits the six cosine palettes as physical strips — each one a live render of that work's actual coefficients, not a saved gradient image. A 600×1 ImageData is filled by evaluating the palette formula in JavaScript, then CSS stretches the single row to strip height. The same gamma (0.91) used by the shader is applied so the strip matches the hung work exactly:

for (let x = 0; x < 600; x++) {
  const t = x / 600;                       // one full period
  for (let ch = 0; ch < 3; ch++) {
    const c = P.a[ch] + P.b[ch] * Math.cos(TAU * (P.c[ch] * t + P.d[ch]));
    img.data[x*4 + ch] = 255 * Math.min(1, Math.pow(Math.max(c, 0), 0.91));
  }
  img.data[x*4 + 3] = 255;
}

js/main.js — strips rendered from the coefficients

One GSAP lesson was learned here the hard way: the strip's hover effect (a CSS transform transition) fought the GSAP scaleX reveal tween for control of the same property, and the strips froze at zero width. The fix is a rule worth keeping: never let CSS transitions and GSAP animate the same property on the same element. GSAP now reveals the wrapper; CSS hovers the strip inside it.

09 · Resilience

The overlay that almost hung the show

The site shipped with a text fallback for browsers without WebGL — and a genuine bug: its CSS said .nogl{ display:grid } while the markup relied on the hidden attribute. The hidden attribute only works through the browser's built-in [hidden]{ display:none } rule, and any author declaration on the element outranks it. The apology overlay was therefore permanently visible, sitting opaque at z-index:90 above a gallery that was rendering perfectly underneath it. Moral: if a stylesheet sets display on an element, the hidden attribute is decoration — toggle a class instead.

The fallback itself was also rebuilt as a matter of principle: a gallery should never greet a visitor with an apology. If WebGL is genuinely unavailable, the same escape-time mathematics now runs per-pixel in JavaScript into ImageData, at reduced resolution (≤560px wide, 220 iterations) — every work, the hero, the atlas and the viewing rooms all still hang. In software mode the rooms trade the continuous descent for render-on-demand, coalesced to one frame with requestAnimationFrame. The text fallback survives only for browsers with no canvas at all.

10 · Craft notes

The last ten percent

Details that make the difference between a render and a room:

11 · Replication

Build your own wing

No AI-generated raster assets were used anywhere on this site — the medium is the point: every image, including the Mandelbrot atlas and the palette strips, is computed live at view time. The only "assets" are two font families and one inline SVG noise tile. (Zero image-generation prompts were used in the making of this gallery.)