Cyclone [369]: a volumetric storm in 369 characters
XorDev's 369-character GLSL storm, running live on WebGL with the pipeline broken into the four steps the source video walks through — ray and basis, turbulence, emission, tonemap — plus knobs for the step count and octave cutoff.
How it works
- The whole piece is one fragment shader: 90 ray-march steps per pixel, no geometry, no textures, no buffers. Each step advances
zalong a perspective ray (normalize(vec3(I+I,0) − iResolution.xyy), pushed 9 units in), warps the sample point, measures a distance, and adds glow inversely proportional to that distance. The four stages below are the four things that happen inside that loop. - 02 · Ray & basis.
a = (p.y − length(p.xz)) / 2 − Tis a cone: height minus radius, spun by time.p.xz *= mat2(cos(a + T + vec4(0, 5, 8, 0)))is the golf trick — cosines at offsets 0 / 5 / 8 / 0 radians approximate (cos, −sin, sin, cos) closely enough to act as a 2D rotation, in 30 characters instead of 60. With only this stage on you see the bare funnel. - 03 · Turbulence.
for(…; d < 4.; p += sin(p.yzx * d − Z) / d) d /= .9;— an octave loop that starts at frequency 2 and multiplies by 1/0.9 until it passes 4 (seven octaves).p.yzxcycles the axes each pass so no octave is aligned with the previous one;Z.x = 6Tdrifts only the x-phase, which is what makes the plasma stream rather than boil. - 04 · Emission. The distance is
min(length(p.xz), 8 − |p.y|)— a cylinder bound — divided by15 (2 + cos a), so steps get shorter near the core. Glow isvec4(7, 5, z, 0) · d / length(t.xz − (p.xz/2 + 3)·sin a): warm (7, 5) up front, and the blue channel is the depthz, so the far side of the storm turns cold by itself. The inverse distance is what makes the core blow out. - 05 · Tonemap.
O = tanh(O·O / 1e3): squaring adds contrast, dividing sets exposure, tanh rolls the top off so the core goes to a soft white instead of clipping. Stage 04 in the sample shows the same frame with a plain clamp — the difference is the entire 'filmic' look. - The port keeps the maths character-for-character but not the golf: every variable is initialised (the original relies on GPU zeros that the spec does not promise and Apple GPUs do not give),
tanhis a polyfill (GLSL ES 1.0 has none), and both loops have constant bounds with an earlybreak(ES 1.0 requires it). Stages are three uniform switches that default to the original. - Rendered at 0.7× CSS pixels, never above DPR 1, and paused on
visibilitychange. Ninety steps × seven octaves is about 630sin()per pixel; at a 760×400 frame that is free, at a Retina full-bleed it is the difference between 60 fps and 20. - Under reduced motion it renders one frame at t = 7.5 s and stops — a still of the storm, not an empty box.
Numbers
- Steps
- 90 per pixel (knob 20–90)
- Octaves
- d from 2 to 4 at ×1/0.9 → 7 passes (knob 2.2–6)
- Rotation basis
- mat2(cos(a + T + vec4(0, 5, 8, 0)))
- Bound
- min(length(p.xz), 8 − |p.y|) / 15 / (2 + cos a)
- Tint
- vec4(7, 5, z, 0) — blue = depth
- Tonemap
- tanh(O² / 1000)
- Backing store
- 0.7 × CSS px, DPR capped at 1
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Port XorDev's 'Cyclone [369]' (shadertoy.com/view/N3dGRM, CC BY-NC-SA 3.0 — keep the attribution in the file) to a single self-contained HTML page on WebGL1. Full-screen canvas, one fullscreen triangle, fragment shader with uniforms iResolution, iTime, uSteps, uOct, uTurb, uColor, uTone. Reproduce the original maths exactly but initialise every variable, replace tanh with a polyfill th(x) = (e^{2x}−1)/(e^{2x}+1) on x clamped to ±20, and use constant loop bounds with early break: for i in 0..90 (break at uSteps): p = z * normalize(vec3(I+I, 0) − iResolution.xyy); Z = vec3(6*T, 0, 0); p.z += 9; t = p; d = 2; a = (p.y − length(p.xz)) / d − T; p.xz *= mat2(cos(a + T + vec4(0, 5, 8, 0))); if uTurb: for k in 0..16 { if d >= uOct break; d /= .9; p += sin(p.yzx * d − Z) / d; }; z += d = min(length(p.xz), 8 − abs(p.y)) / 15 / (2 + cos(a)); O += (uColor ? vec4(7, 5, z, 0) : vec4(5)) * d / length(t.xz − (p.xz/2 + 3) * sin(a)). After the loop O = uTone ? th(O*O/1e3) : clamp(O*O/1e3, 0, 1). Add a chapter strip of four buttons mapping to (uTurb, uColor, uTone) = (0,0,1), (1,0,1), (1,1,0), (1,1,1), a 'source' toggle that shows the original 369-character shader with the lines of the current stage highlighted, and two range inputs for steps (20–90) and octave cutoff (2.2–6). Render at 0.7 × CSS pixels with devicePixelRatio capped at 1, pause the loop on visibilitychange, show an fps readout, and under prefers-reduced-motion draw one frame at t = 7.5 and stop. Show a 'needs WebGL' message if getContext fails.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cyclone [369] — a volumetric storm in 369 characters</title>
<!--
Shader: "Cyclone [369]" by @XorDev — https://www.shadertoy.com/view/N3dGRM
ShaderToy default licence: CC BY-NC-SA 3.0. The 369-character original is
reproduced verbatim in the code box below; the GLSL in <script> is a port
to WebGL1 (explicit initialisation, a tanh polyfill, constant loop bounds)
with stage switches added so the pipeline can be walked one step at a time.
-->
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; background: #05040a; color: #fff; overflow: hidden; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* the chapter strip — mirrors the five title cards in the source video */
.panel {
position: absolute; left: 12px; right: 12px; bottom: 12px;
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
pointer-events: none;
}
.panel > * { pointer-events: auto; }
.stage {
display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 12px 0 6px;
border-radius: 99px; border: 1px solid #ffffff2a; background: #0c0a1acc; color: #ffffffb8;
font-size: 11px; font-weight: 500; letter-spacing: .01em; cursor: pointer;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
transition: border-color 250ms, color 250ms, background-color 250ms;
}
.stage i { display: grid; place-items: center; width: 18px; height: 18px; border-radius: 6px; background: #ffffff14; font-style: normal; font-size: 9px; font-weight: 700; color: #b9a6ff; }
.stage.on { border-color: #b9a6ff99; color: #fff; background: #241a4acc; }
.stage.on i { background: #7b5cff; color: #fff; }
.knobs { margin-left: auto; display: flex; gap: 10px; align-items: center; height: 30px; padding: 0 12px; border-radius: 99px; border: 1px solid #ffffff2a; background: #0c0a1acc; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
.knobs label { display: flex; align-items: center; gap: 6px; font: 500 10px/1 ui-monospace, Menlo, monospace; color: #ffffff99; }
.knobs b { color: #fff; font-weight: 600; min-width: 26px; text-align: right; }
.knobs input { width: 70px; accent-color: #b9a6ff; }
.tag { position: absolute; left: 14px; top: 12px; font: 500 10px/1 ui-monospace, Menlo, monospace; letter-spacing: .08em; color: #ffffff80; text-transform: uppercase; }
.tag b { color: #d9c9ff; font-weight: 600; }
.credit { position: absolute; right: 14px; top: 12px; font-size: 10px; letter-spacing: .04em; color: #ffffff80; }
.credit a { color: #d9c9ff; text-decoration: none; }
.nogl { position: absolute; inset: 0; display: none; place-items: center; text-align: center; font-size: 12px; color: #ffffffb0; padding: 24px; }
/* the 369 characters themselves, for reading — the render above is them */
.code {
position: absolute; left: 12px; right: 12px; bottom: 54px; max-height: 44%; overflow: auto;
padding: 12px 14px; border-radius: 14px; border: 1px solid #ffffff1f; background: #07061299;
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
font: 500 10.5px/1.55 ui-monospace, Menlo, monospace; color: #e8e2ff; white-space: pre; tab-size: 4;
opacity: 0; transform: translateY(8px); pointer-events: none; transition: opacity 300ms, transform 300ms;
}
.code.show { opacity: 1; transform: none; pointer-events: auto; }
.code .c { color: #8f88b0; } .code .k { color: #ff8fb8; } .code .f { color: #8fd0ff; } .code .n { color: #ffd479; }
.code mark { background: #7b5cff55; color: inherit; border-radius: 3px; }
@media (prefers-reduced-motion: reduce) { .code, .stage { transition: none; } }
</style>
<canvas id="cv"></canvas>
<div class="nogl" id="nogl">This piece needs WebGL. Your browser has it switched off.</div>
<div class="tag">Cyclone [369] · <b id="chars">369</b> chars · <b id="fps">–</b> fps</div>
<div class="credit">shader by <a href="https://www.shadertoy.com/view/N3dGRM" target="_blank" rel="noopener">@XorDev</a> · CC BY-NC-SA</div>
<pre class="code" id="code"><span class="c">/* "Cyclone [369]" by @XorDev (ShaderToy N3dGRM) */</span>
<span class="k">void</span> <span class="f">mainImage</span>(<span class="k">out vec4</span> O, <span class="k">vec2</span> I) {
<span class="k">float</span> z,d,a,i,T=iTime;
<span class="k">for</span>(O*=i; i++<<span class="n">90.</span>;) {
<mark data-s="1"> <span class="k">vec3</span> p = z * <span class="f">normalize</span>(<span class="k">vec3</span>(I+I,<span class="n">0</span>) - iResolution.xyy),t,Z;
Z.x=<span class="n">6.</span>*T, p.z+=<span class="n">9.</span>, t=p;
d=<span class="n">2.</span>, a=(p.y-<span class="f">length</span>(p.xz))/d-T;</mark>
<mark data-s="2"> <span class="k">for</span>(p.xz *= <span class="k">mat2</span>(<span class="f">cos</span>(a+T+<span class="k">vec4</span>(<span class="n">0</span>, <span class="n">5</span>, <span class="n">8</span>, <span class="n">0</span>)));
d<<span class="n">4.</span>; p+=<span class="f">sin</span>(p.yzx * d - Z) / d) d/=<span class="n">.9</span>;</mark>
<mark data-s="3"> z += d = <span class="f">min</span>(<span class="f">length</span>(p.xz), <span class="n">8.</span>-<span class="f">abs</span>(p.y)) / <span class="n">15.</span>/(<span class="n">2.</span>+<span class="f">cos</span>(a));
O += <span class="k">vec4</span>(<span class="n">7</span>, <span class="n">5</span>, z, <span class="n">0</span>) * d / <span class="f">length</span>(t.xz-(p.xz/<span class="n">2.</span>+<span class="n">3.</span>)*<span class="f">sin</span>(a));</mark>
}
<mark data-s="4"> O = <span class="f">tanh</span>(O*O / <span class="n">1e3</span>);</mark>
}</pre>
<div class="panel">
<button class="stage" data-s="1"><i>02</i>Ray & basis</button>
<button class="stage" data-s="2"><i>03</i>+ turbulence</button>
<button class="stage" data-s="3"><i>04</i>+ emission</button>
<button class="stage on" data-s="4"><i>05</i>+ tonemap</button>
<button class="stage" id="src"><i>{}</i>source</button>
<div class="knobs">
<label>steps <input id="steps" type="range" min="20" max="90" value="90"><b id="stepsv">90</b></label>
<label>octaves <input id="oct" type="range" min="2.2" max="6" step=".1" value="4"><b id="octv">4.0</b></label>
</div>
</div>
<script>
const cv = document.getElementById("cv");
const gl = cv.getContext("webgl", { antialias: false, alpha: false, powerPreference: "high-performance" });
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!gl) document.getElementById("nogl").style.display = "grid";
/* ---------- the port ----------
Differences from the 369-character original, and why:
· every variable is initialised — the golf relies on GPU zeros that the
GLSL spec does not promise, and Safari on Apple silicon does not give;
· tanh is a polyfill — GLSL ES 1.0 has no tanh (WebGL2 does; this runs
on WebGL1 so it works on more phones);
· loop bounds are constants with an early `break` — ES 1.0 requires it;
· three stage switches and two knobs. With all of them at their defaults
the maths is character-for-character the original. */
const FRAG = `
precision highp float;
uniform vec2 iResolution; uniform float iTime;
uniform float uSteps, uOct, uTurb, uColor, uTone;
vec4 th(vec4 x) { x = clamp(x, -20., 20.); vec4 e = exp(2. * x); return (e - 1.) / (e + 1.); }
void main() {
vec2 I = gl_FragCoord.xy;
vec4 O = vec4(0.);
float z = 0., d = 0., a = 0., T = iTime;
for (float i = 0.; i < 90.; i++) {
if (i >= uSteps) break;
// 02 · perspective ray, pushed 9 units into the scene; a = cone height − radius, spun by time
vec3 p = z * normalize(vec3(I + I, 0.) - iResolution.xyy), t, Z = vec3(0.);
Z.x = 6. * T; p.z += 9.; t = p;
d = 2.; a = (p.y - length(p.xz)) / d - T;
// the "golfed rotation basis": cos of (a, a+5, a+8, a) is a 2×2 rotation, near enough
p.xz *= mat2(cos(a + T + vec4(0., 5., 8., 0.)));
// 03 · multi-octave swirl: p.yzx cycles the axes so no octave lines up with the last
if (uTurb > .5) for (int k = 0; k < 16; k++) { if (d >= uOct) break; d /= .9; p += sin(p.yzx * d - Z) / d; }
// 04 · cylinder-bounded distance, inverse-distance emission, warm→cold with depth
z += d = min(length(p.xz), 8. - abs(p.y)) / 15. / (2. + cos(a));
vec4 tint = uColor > .5 ? vec4(7., 5., z, 0.) : vec4(5.);
O += tint * d / length(t.xz - (p.xz / 2. + 3.) * sin(a));
}
// 05 · filmic squash: tanh(O²/1000) keeps the core from clipping to white
O = uTone > .5 ? th(O * O / 1e3) : clamp(O * O / 1e3, 0., 1.);
gl_FragColor = vec4(O.rgb, 1.);
}`;
const VERT = `attribute vec2 a; void main(){ gl_Position = vec4(a, 0., 1.); }`;
let prog, U = {}, t0 = performance.now(), frames = 0, fpsT = t0;
if (gl) {
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(s)); return s; };
prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(prog); gl.useProgram(prog);
const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "a"); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
for (const n of ["iResolution", "iTime", "uSteps", "uOct", "uTurb", "uColor", "uTone"]) U[n] = gl.getUniformLocation(prog, n);
}
// stage → the three switches. 4 = the whole shader.
const STAGES = { 1: [0, 0, 1], 2: [1, 0, 1], 3: [1, 1, 0], 4: [1, 1, 1] };
let stage = 4, steps = 90, oct = 4;
const code = document.getElementById("code");
function setStage(s) {
stage = s;
document.querySelectorAll(".stage[data-s]").forEach((b) => b.classList.toggle("on", +b.dataset.s === s));
// highlight the lines this stage adds
code.querySelectorAll("mark").forEach((m) => { m.style.background = +m.dataset.s === s ? "#7b5cff66" : (+m.dataset.s < s ? "#ffffff0a" : "transparent"); m.style.opacity = +m.dataset.s <= s ? 1 : .35; });
draw(true);
}
document.querySelectorAll(".stage[data-s]").forEach((b) => b.addEventListener("click", () => setStage(+b.dataset.s)));
document.getElementById("src").addEventListener("click", (e) => { code.classList.toggle("show"); e.currentTarget.classList.toggle("on"); });
const stepsEl = document.getElementById("steps"), octEl = document.getElementById("oct");
stepsEl.addEventListener("input", () => { steps = +stepsEl.value; document.getElementById("stepsv").textContent = steps; draw(true); });
octEl.addEventListener("input", () => { oct = +octEl.value; document.getElementById("octv").textContent = oct.toFixed(1); draw(true); });
/* Render at 0.7× CSS pixels and never above DPR 1: ninety ray steps with a
seven-octave inner loop is ~630 sin() per pixel. At 760×400 that is
nothing; at a Retina full-bleed it is the difference between 60 and 20. */
function size() {
// the code box sits above the chapter strip, which wraps to two rows on narrow frames
code.style.bottom = document.querySelector(".panel").offsetHeight + 20 + "px";
const k = 0.7;
cv.width = Math.max(1, Math.floor(cv.clientWidth * k)); cv.height = Math.max(1, Math.floor(cv.clientHeight * k));
if (gl) gl.viewport(0, 0, cv.width, cv.height);
draw(true);
}
let visible = true, raf = 0;
function draw(once) {
if (!gl) return;
const now = performance.now();
// reduced motion: a fixed instant of the storm, still — it is a still image then
const t = reduce ? 7.5 : (now - t0) / 1000;
const [turb, color, tone] = STAGES[stage];
gl.uniform2f(U.iResolution, cv.width, cv.height); gl.uniform1f(U.iTime, t);
gl.uniform1f(U.uSteps, steps); gl.uniform1f(U.uOct, oct);
gl.uniform1f(U.uTurb, turb); gl.uniform1f(U.uColor, color); gl.uniform1f(U.uTone, tone);
gl.drawArrays(gl.TRIANGLES, 0, 3);
frames++;
if (now - fpsT > 1000) { document.getElementById("fps").textContent = Math.round(frames * 1000 / (now - fpsT)); frames = 0; fpsT = now; }
if (!once && visible && !reduce) raf = requestAnimationFrame(() => draw(false));
}
// stop burning the GPU when nobody is looking
const restart = () => { visible = !document.hidden; if (visible && !raf && !reduce) raf = requestAnimationFrame(() => draw(false)); if (!visible) { cancelAnimationFrame(raf); raf = 0; } };
document.addEventListener("visibilitychange", restart);
addEventListener("resize", size);
size(); setStage(4);
if (!reduce) raf = requestAnimationFrame(() => draw(false));
</script>Where it breaks
- Licence: ShaderToy's default is CC BY-NC-SA 3.0. That permits this page (non-commercial, attributed, same licence on the port) and does not permit dropping the shader into a product. The attribution comment at the top of the file is part of the work.
- The golfed original leaves
O,z,i,tandZ.yzuninitialised. On most desktop drivers they read as zero; on Apple GPUs and some Android drivers they read as garbage and the first frame is noise. Initialising them costs characters, which is why the original does not — a port has no reason to inherit that. p.xz *= mat2(cos(a + T + vec4(0, 5, 8, 0)))is not an exact rotation (cos 5 ≈ 0.284 and cos 8 ≈ −0.146 are not ±sin), so the transform also scales slightly per step. That is part of the look; 'fixing' it to a true rotation changes the storm.- Ninety steps at full-bleed on a phone GPU throttles within a minute. The 0.7× backing store and the visibility pause are the minimum; a real page should also drop
uStepson small viewports.