Flow

Effects / WebGL shader

Ray-marched fractals

An infinitely detailed 3D fractal, lit and slowly rotating, rendered entirely on the GPU.

Prompt to build it

Build a slowly rotating ray-marched Mandelbulb with copper lighting. Write a fragment shader with a Mandelbulb distance estimator (power 8), ~64 march steps, normals by central differences, and glow from step count. Render at half resolution and upscale. Make it responsive, cap devicePixelRatio at 2, pause when off-screen, and show a still frame when prefers-reduced-motion is set. No external libraries.

Tested code

One self-contained HTML file with no libraries: the same code that runs above. Your AI gets it from Flow's get_effect tool.

Show the code
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Ray-marched fractals | Flow effect</title> <!-- Flow effect: Ray-marched fractals (https://flow.donta.io/effects/ray-marched-fractals) Self-contained: no libraries. Copy the .fx-stage element, the CSS and the script into your project and size .fx-stage however you like. --> <style> :root { --display: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif; --body: ui-sans-serif, system-ui, sans-serif; } html, body { margin: 0; min-height: 100%; background: #0e0e10; display: grid; place-items: center; } .fx-stage { width: min(100vw, 960px); } /* Stage that every effect fills. Container units (cqw/cqmin) size the art. */ .fx-stage { position: relative; aspect-ratio: 4 / 3; overflow: hidden; border-radius: 16px; container-type: size; background: #111; isolation: isolate; } .fx-fill { position: absolute; inset: 0; } .fx-fallback { position: absolute; inset: 0; } .fx-center { display: grid; place-items: center; } @media (prefers-reduced-motion: reduce) { .fx-stage *, .fx-stage *::before, .fx-stage *::after { animation-play-state: paused !important; } } </style> </head> <body> <div class="fx-stage"><canvas class="fx-fill" aria-hidden="true" style="width:100%;height:100%;display:block"></canvas></div> <script> function flowLoop(el, frame, opts) { opts = opts || {}; if (matchMedia("(prefers-reduced-motion: reduce)").matches) { for (let i = 0; i < (opts.stillFrames || 1); i++) frame(2 + i / 60); return function () {}; } let raf = 0, onScreen = true, last = 0; const start = performance.now(), gap = opts.fps ? 1000 / opts.fps : 0; const io = new IntersectionObserver(function (e) { onScreen = e[0].isIntersecting; }); io.observe(el); function tick(now) { raf = requestAnimationFrame(tick); if (!onScreen || document.hidden || (gap && now - last < gap)) return; last = now; frame((now - start) / 1000); } raf = requestAnimationFrame(tick); return function () { cancelAnimationFrame(raf); io.disconnect(); }; } function flowShader(canvas, frag, o) { o = o || {}; const gl = canvas.getContext("webgl2", { antialias: false }); function fail(msg) { console.warn("[flow]", msg); canvas.style.background = o.fallback || "#111"; } if (!gl) return fail("WebGL2 not available"); function compile(type, src) { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { fail(gl.getShaderInfoLog(s)); return null; } return s; } const vs = compile(gl.VERTEX_SHADER, "#version 300 es\nin vec2 p;void main(){gl_Position=vec4(p,0.0,1.0);}"); const fs = compile(gl.FRAGMENT_SHADER, frag); if (!vs || !fs) return; const prog = gl.createProgram(); gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog); gl.useProgram(prog); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const loc = gl.getAttribLocation(prog, "p"); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); const U = function (n) { return gl.getUniformLocation(prog, n); }; const uRes = U("u_res"), uTime = U("u_time"), uMouse = U("u_mouse"), uDpr = U("u_dpr"); const dpr = Math.min(devicePixelRatio || 1, 2) * (o.scale || 1); let mouse = [0.5, 0.5]; canvas.addEventListener("pointermove", function (e) { const r = canvas.getBoundingClientRect(); mouse = [(e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height]; }); function size() { canvas.width = Math.max(1, Math.round(canvas.clientWidth * dpr)); canvas.height = Math.max(1, Math.round(canvas.clientHeight * dpr)); gl.viewport(0, 0, canvas.width, canvas.height); } size(); new ResizeObserver(size).observe(canvas); return flowLoop(canvas, function (t) { gl.uniform2f(uRes, canvas.width, canvas.height); gl.uniform1f(uTime, t); gl.uniform2f(uMouse, mouse[0], mouse[1]); gl.uniform1f(uDpr, dpr); gl.drawArrays(gl.TRIANGLES, 0, 3); }); } const FRAG = `#version 300 es precision highp float; uniform vec2 u_res; uniform float u_time; uniform vec2 u_mouse; uniform float u_dpr; out vec4 o; float DE(vec3 p) { vec3 z = p; float dr = 1.0; float r = 0.0; for (int i = 0; i < 7; i++) { r = length(z); if (r > 2.0) break; float th = acos(clamp(z.z / r, -1.0, 1.0)) * 8.0; float ph = atan(z.y, z.x) * 8.0; dr = pow(r, 7.0) * 8.0 * dr + 1.0; z = pow(r, 8.0) * vec3(sin(th) * cos(ph), sin(ph) * sin(th), cos(th)) + p; } return 0.5 * log(r) * r / dr; } vec3 rotY(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z); } vec3 rotX(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(p.x, c * p.y - s * p.z, s * p.y + c * p.z); } void main() { vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / min(u_res.x, u_res.y); float a = u_time * 0.25; vec3 ro = rotX(rotY(vec3(0.0, 0.0, 3.3), a), 0.35); vec3 rd = normalize(rotX(rotY(vec3(uv, -1.9), a), 0.35)); float t = 0.0; int steps = 0; bool hit = false; for (int i = 0; i < 72; i++) { float d = DE(ro + rd * t); if (d < 0.0012) { hit = true; break; } t += d; steps = i; if (t > 6.0) break; } vec3 bg = mix(vec3(0.05, 0.03, 0.12), vec3(0.16, 0.07, 0.24), uv.y + 0.5); if (!hit) { o = vec4(bg + 0.004 * float(steps) * vec3(0.5, 0.3, 0.9), 1.0); return; } vec3 p = ro + rd * t; vec2 e = vec2(0.0015, 0.0); vec3 n = normalize(vec3(DE(p + e.xyy) - DE(p - e.xyy), DE(p + e.yxy) - DE(p - e.yxy), DE(p + e.yyx) - DE(p - e.yyx))); vec3 l = normalize(vec3(0.6, 0.8, 0.5)); float diff = clamp(dot(n, l), 0.0, 1.0); float ao = 1.0 - float(steps) / 72.0; vec3 copper = vec3(0.92, 0.5, 0.28); vec3 col = copper * (0.25 + 0.85 * diff) * ao + vec3(0.35, 0.2, 0.6) * (1.0 - ao) * 0.6; col += pow(clamp(dot(reflect(-l, n), -rd), 0.0, 1.0), 24.0) * 0.5; o = vec4(col, 1.0); }`; flowShader(document.querySelector(".fx-stage canvas"), FRAG, { scale: 0.5, fallback: "#1a0d2e" }); </script> </body> </html>

How it works

For each pixel a ray steps through space using a distance estimator for a fractal (like a Mandelbulb) until it hits the surface, then shades by normal and step count.

Guardrails

  • Render at reduced resolution and fall back to a still image on low-end devices.
  • Respect prefers-reduced-motion: show a still frame instead of the animation.
  • Pause when off-screen or when the tab is hidden (IntersectionObserver + visibilitychange).
  • Keep text off busy animated areas, or give it a solid backing that passes WCAG contrast.
  • One showpiece effect per page. If everything moves, nothing stands out.