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 gallery01 · 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.
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:
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:
| Work | Seed c | Palette |
|---|---|---|
| The Gilded Whorl | −0.7269 + 0.1889i | |
| Prussian Tide | 0.285 + 0.01i | |
| Vermilion Sermon | −0.8 + 0.156i | |
| Dendrite, Winter | 0 + 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:
- Loading as vernissage. The loader bar advances as each work renders (one per animation frame, so the bar genuinely tracks progress), then the wordmark letters rise and the wall lights come up.
- Frames are CSS. Wood is a dark padded box; the bezel is a 1px brass inset border; the plate is a tiny gradient plaque. Hover lifts the frame 6px on a 0.8s expo curve and warms the bezel.
- Idle motion. The hero is never still: a log-space breathing zoom, 0.008 rad/s of rotation, and a slow palette phase drift. It renders at ~62% resolution and 30fps — atmosphere, not action.
- Film grain. A fixed SVG feTurbulence layer at 5% opacity in overlay blend, stepped 4 frames per second, keeps large flat areas alive.
- Reduced motion. With prefers-reduced-motion, the hero renders one still, rooms open on a static view (wheel still zooms, instantly), and all CSS animation collapses to ~0ms.
- Resilience. The GL canvas listens for webglcontextlost, re-initializes on restore, and re-renders every frame's canvas; devicePixelRatio is capped at 2 and render widths at 1500px.
- The gallery rail. A fixed column of roman numerals tracks which work is on the wall, driven by an IntersectionObserver at 45% visibility. It appears only while the collection itself is in view, and only above 1100px — a docent, not a fixture.
- Double-click to dive. In the viewing room, a double-click re-aims the descent at the pointed-at coordinate: screen position is mapped back through the view rotation to a complex number, the centre glides there on a 1.1s tween while the target log-scale drops by ln 2.4.
- Anchor manners. Every [id] carries scroll-margin-top:4.5rem so nav jumps land below the fixed header instead of underneath it.
11 · Replication
Build your own wing
- Create one hidden WebGL canvas, compile the escape-time shader above, and write a renderInto(ctx2d, params, w, h) blit helper. This is the entire engine.
- Pick seeds: wander the boundary of the Mandelbrot set (spiral-rich territory sits near the seahorse valley, c ≈ −0.75 + 0.1i). Each interesting c is a new work.
- Design one cosine palette per work with the phase trick (d ≈ 0.5, dark at t=0). Test at several zoom depths — a palette that sings at ×1 can shout at ×1,000.
- Frame each work: fixed center, scale, angle, aspect ratio. Commit. The refusal to let visitors re-parameterize the wall view is what makes it feel curated.
- Compute the repelling fixed point and aim the viewing-room descent at it. Ease everything in log space.
- Add the atlas: flip the same shader to Mandelbrot mode with one uniform, pin your seeds over it, and let the pins walk visitors to the works. A collection reads differently once its provenance is on the wall.
- Exhibit the palettes themselves: render each cosine curve into a 600×1 strip from the live coefficients. Showing the pigment is what convinces a visitor the works are computed, not photographed.
- Write wall text in the voice of a catalogue essay — about weather, patina, and rhetoric, never about iteration counts. The math is the medium, not the subject.
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.)