{"slug":"vortex","number":"28","title":"Spaghetti vortex","era":"Scientific visualization · 2026","summary":"A concentrating 3D vortex. Trails spiral in as the core slims. Illustration, not a solver.","description":"Parametric helices scaled by the 2026 OpenAI paper's leading laws: radius follows tau^(1/2), height tau^(1/2−h), with illustrative h = 0.005 and tau = 1−t/T*. Both shrink; radius shrinks faster. Drag to orbit, scrub toward T*, or follow the core with a labeled uniform zoom. Color shows normalized radius. Oscillatory corrections are omitted. Illustration of the proposed blowup construction; not a numerical reproduction of the proof.","apis":["WebGL 1","GLSL ES 1.0","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["webgl","3d","particles"],"hasGlsl":true,"interaction":"Drag to orbit; auto-rotation stops after the first drag. Play, pause, scrub the approach, or follow the core.","url":"https://3d-retro.com/experiments/vortex","example_url":"https://3d-retro.com/examples/vortex.html","api_url":"https://3d-retro.com/api/v1/experiments/vortex","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"A concentrating 3D vortex. Seekable schematic geometry, not a fluid solver.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/vortex\" />\n  <link rel=\"icon\" href=\"data:,\" />\n  <title>Spaghetti vortex · 3d retro graphics</title>\n  <style>\n    * { box-sizing: border-box; }\n    html, body { margin: 0; width: 100%; height: 100%; background: #000; color: #c8d7d0; font: 11px/1.4 ui-monospace, monospace; }\n    body { display: grid; grid-template-rows: minmax(0, 1fr) auto; }\n    #scene { position: relative; min-height: 0; overflow: hidden; }\n    canvas { display: block; width: 100%; height: 100%; touch-action: none; }\n    #readout { position: absolute; top: 10px; left: 12px; right: 12px; pointer-events: none; display: flex; flex-wrap: wrap; gap: 3px 18px; }\n    #legend { position: absolute; bottom: 9px; left: 12px; pointer-events: none; font-size: 10px; }\n    #ramp { display: inline-block; width: 48px; height: 3px; margin: 0 5px 2px; background: linear-gradient(90deg, #f2b45c, #6ee7b7); }\n    footer { padding: 4px 10px 7px; background: #080d0b; border-top: 1px solid #22382d; }\n    #controls { display: flex; align-items: center; gap: 4px 9px; flex-wrap: wrap; }\n    button, input { font: inherit; accent-color: #6ee7b7; }\n    button { min-height: 44px; padding: 0 9px; color: inherit; background: transparent; border: 1px solid #355144; cursor: pointer; }\n    button:hover { background: #17271f; }\n    :focus-visible { outline: 2px solid #f2b45c; outline-offset: 2px; }\n    label { display: flex; align-items: center; min-height: 44px; gap: 5px; cursor: pointer; }\n    #seek { flex: 1 1 115px; }\n    input[type=range] { width: 100%; min-width: 40px; height: 36px; }\n    input[type=checkbox] { width: 17px; height: 17px; margin: 0; }\n    #note { margin: 5px 0 0; font-size: 10px; color: #9aafa3; }\n    #fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 15px; text-align: center; background: #000; }\n    #fallback[hidden] { display: none; }\n    @media (max-height: 320px) { #readout { top: 4px; font-size: 9px; gap: 0 8px; } #legend { bottom: 3px; font-size: 9px; } footer { padding: 2px 6px; } #note { margin-top: 2px; font-size: 9px; } #controls { gap: 2px 5px; } button { padding: 0 5px; } }\n  </style>\n</head>\n<body>\n<div id=\"scene\">\n  <canvas id=\"c\" aria-label=\"Three-dimensional concentrating vortex. Drag to orbit; use the approach slider to seek.\"></canvas>\n  <div id=\"readout\"><span id=\"time\"></span><span id=\"zoom\"></span><span id=\"ruler\"></span></div>\n  <div id=\"legend\">Normalized radius · 0 <span id=\"ramp\"></span> 1</div>\n  <div id=\"fallback\" role=\"status\" hidden></div>\n</div>\n<footer>\n  <div id=\"controls\">\n    <button id=\"play\" type=\"button\">Pause</button>\n    <label id=\"seek\">Approach <input id=\"approach\" type=\"range\" min=\"0\" max=\"12\" step=\"0.01\" value=\"0.6\" aria-label=\"Approach, minus log ten of remaining time\" /></label>\n    <label><input id=\"follow\" type=\"checkbox\" checked /> Follow core</label>\n    <button id=\"reset\" type=\"button\">Reset</button>\n  </div>\n  <p id=\"note\">Illustration of the proposed blowup construction; not a numerical reproduction of the proof.</p>\n</footer>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#6ee7b7\",\n    core: \"#f2b45c\",\n    trail: 0.35,\n    speed: 1\n  };\n  // Leading scales: OpenAI (2026), section 2.1. h is illustrative.\n  // These scales do not define a velocity field or simulate its corrections.\n  const H = 0.005, MAX_S = 12, COUNT = 960, SAMPLES = 24;\n  function tau(s) { return Math.pow(10, -s); }\n  function scales(s) {\n    const t = tau(s);\n    return { radial: Math.pow(t, 0.5), axial: Math.pow(t, 0.5 - H), speed: Math.pow(t, -0.5 - H) };\n  }\n  function seed(i) {\n    // Fixed low-discrepancy seeds: reset and reverse seeking need no RNG state.\n    return { phase: (i * 0.61803398875) % 1, angle: (i * 2.60258057) % (2 * Math.PI), side: i % 2 ? 1 : -1 };\n  }\n  function position(p, s) {\n    const k = scales(s), cycle = p.phase + s * 0.65;\n    const q = cycle - Math.floor(cycle);\n    // An inward helix bends into two axial exits. Recycling is a schematic\n    // seeding device; history segments across a recycle boundary are omitted.\n    const r = 0.14 + 1.36 * Math.exp(-3.6 * q);\n    const angle = p.angle + s * 6 + q * 10;\n    return [k.radial * r * Math.cos(angle), k.axial * p.side * (0.08 + 2.1 * q * q), k.radial * r * Math.sin(angle), r / 1.5, Math.floor(cycle)];\n  }\n  function rgb(hex) {\n    const n = parseInt(hex.slice(1), 16);\n    return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];\n  }\n  const canvas = document.getElementById(\"c\"), fallback = document.getElementById(\"fallback\");\n  const play = document.getElementById(\"play\"), slider = document.getElementById(\"approach\");\n  const follow = document.getElementById(\"follow\"), reset = document.getElementById(\"reset\");\n  const timeText = document.getElementById(\"time\"), zoomText = document.getElementById(\"zoom\"), ruler = document.getElementById(\"ruler\");\n  document.getElementById(\"ramp\").style.background = \"linear-gradient(90deg,\" + PARAMS.core + \",\" + PARAMS.color + \")\";\n  let gl;\n  try { gl = canvas.getContext(\"webgl\", { alpha: false, antialias: true }); } catch (_) { /* Fallback below. */ }\n  function fail(message) {\n    fallback.hidden = false;\n    fallback.textContent = message;\n    for (const el of [play, slider, follow, reset]) el.disabled = true;\n    // A failed WebGL creation still allows a Canvas 2D explanation.\n    if (!gl) {\n      const ctx = canvas.getContext(\"2d\");\n      if (ctx) {\n        canvas.width = Math.max(1, canvas.clientWidth); canvas.height = Math.max(1, canvas.clientHeight);\n        ctx.fillStyle = \"#000\"; ctx.fillRect(0, 0, canvas.width, canvas.height);\n        ctx.fillStyle = \"#c8d7d0\"; ctx.font = \"12px monospace\"; ctx.textAlign = \"center\";\n        ctx.fillText(\"WebGL unavailable\", canvas.width / 2, canvas.height / 2);\n      }\n    }\n  }\n  if (!gl) { fail(\"WebGL is unavailable. Enable WebGL to view this illustration.\"); return; }\n  const VERT = `attribute vec3 a_pos;\nattribute vec2 a_style;\nuniform mat4 u_mvp;\nuniform float u_zoom;\nuniform float u_point;\nvarying vec2 v_style;\nvarying float v_depth;\nvoid main() {\n  gl_Position = u_mvp * vec4(a_pos * u_zoom, 1.0);\n  gl_PointSize = u_point;\n  v_style = a_style;\n  v_depth = clamp(1.5 - gl_Position.w * 0.1, 0.25, 1.0);\n}`;\n  const FRAG = `precision mediump float;\nuniform vec3 u_outer;\nuniform vec3 u_core;\nvarying vec2 v_style;\nvarying float v_depth;\nvoid main() {\n  vec3 color = mix(u_core, u_outer, smoothstep(0.08, 0.85, v_style.x));\n  gl_FragColor = vec4(color, v_style.y * v_depth);\n}`;\n  function compile(type, source) {\n    const shader = gl.createShader(type);\n    gl.shaderSource(shader, source); gl.compileShader(shader);\n    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(\"Could not compile the vortex shader.\");\n    return shader;\n  }\n  // Column-major orbit matrices shared with the gallery's lowpoly example.\n  function mul(a, b) {\n    const out = new Float32Array(16);\n    for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {\n      out[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];\n    }\n    return out;\n  }\n  function camera(aspect, pitch, yaw) {\n    const f = 1 / Math.tan(Math.PI / 8), near = 0.1, far = 50;\n    const proj = new Float32Array([f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)/(near-far),-1, 0,0,2*far*near/(near-far),0]);\n    const cx = Math.cos(pitch), sx = Math.sin(pitch), cy = Math.cos(yaw), sy = Math.sin(yaw);\n    const rx = new Float32Array([1,0,0,0, 0,cx,sx,0, 0,-sx,cx,0, 0,0,0,1]);\n    const ry = new Float32Array([cy,0,-sy,0, 0,1,0,0, sy,0,cy,0, 0,0,0,1]);\n    const view = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,-8.8*Math.max(1,0.8/aspect),1]);\n    return mul(proj, mul(view, mul(rx, ry)));\n  }\n  let prog, buffer, loc;\n  function setup() {\n    prog = gl.createProgram();\n    gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));\n    gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG)); gl.linkProgram(prog);\n    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(\"Could not link the vortex shader.\");\n    buffer = gl.createBuffer();\n    loc = {};\n    for (const key of [\"mvp\", \"zoom\", \"point\", \"outer\", \"core\"]) loc[key] = gl.getUniformLocation(prog, \"u_\" + key);\n    gl.useProgram(prog); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n    for (const [name, size, offset] of [[\"a_pos\", 3, 0], [\"a_style\", 2, 12]]) {\n      const at = gl.getAttribLocation(prog, name);\n      gl.enableVertexAttribArray(at); gl.vertexAttribPointer(at, size, gl.FLOAT, false, 20, offset);\n    }\n    gl.uniform3fv(loc.outer, rgb(PARAMS.color)); gl.uniform3fv(loc.core, rgb(PARAMS.core));\n    gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE);\n    gl.clearColor(0, 0, 0, 1);\n  }\n  try { setup(); } catch (err) { fail(err.message); return; }\n  const motion = matchMedia(\"(prefers-reduced-motion: reduce)\");\n  let s = motion.matches ? 6 : 0.6, playing = !motion.matches, auto = !motion.matches;\n  let yaw = 0.7, pitch = 0.34, pointer = null, lastX = 0, lastY = 0;\n  let raf = 0, last = 0, lost = false, dirty = true;\n  let count = COUNT, slow = 0;\n  const seeds = Array.from({ length: COUNT }, (_, i) => seed(i));\n  // Bounded world-space history ring, regenerated analytically on every seek.\n  // Fixed sample spacing makes history independent of playback frame rate.\n  const history = new Float32Array(COUNT * SAMPLES * 5);\n  const vertices = new Float32Array(COUNT * (SAMPLES - 1) * 2 * 5 + COUNT * 5);\n  let lineCount = 0, vertexCount = 0;\n  function rebuild() {\n    const step = PARAMS.trail / (SAMPLES - 1), tick = Math.floor(s / step), head = tick % SAMPLES;\n    let n = 0;\n    for (let i = 0; i < count; i++) {\n      for (let age = 0; age < SAMPLES; age++) {\n        const slot = (head - age + SAMPLES) % SAMPLES;\n        const t = age === 0 ? s : Math.max(0, (tick - age + 1) * step);\n        history.set(position(seeds[i], t), (i * SAMPLES + slot) * 5);\n      }\n      for (let age = SAMPLES - 1; age > 0; age--) {\n        const a = (i * SAMPLES + (head - age + SAMPLES) % SAMPLES) * 5;\n        const b = (i * SAMPLES + (head - age + 1 + SAMPLES) % SAMPLES) * 5;\n        if (history[a + 4] !== history[b + 4]) continue;\n        for (const at of [a, b]) {\n          vertices[n++] = history[at]; vertices[n++] = history[at+1]; vertices[n++] = history[at+2];\n          vertices[n++] = history[at+3]; vertices[n++] = 0.16 * (1 - age / SAMPLES);\n        }\n      }\n    }\n    lineCount = n / 5;\n    for (let i = 0; i < count; i++) {\n      const at = (i * SAMPLES + head) * 5;\n      vertices[n++] = history[at]; vertices[n++] = history[at+1]; vertices[n++] = history[at+2];\n      vertices[n++] = history[at+3]; vertices[n++] = 0.65;\n    }\n    vertexCount = n / 5;\n    dirty = false;\n  }\n  function draw() {\n    const dpr = Math.min(devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.round(canvas.clientWidth * dpr)), h = Math.max(1, Math.round(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }\n    gl.viewport(0, 0, canvas.width, canvas.height); gl.clear(gl.COLOR_BUFFER_BIT);\n    gl.uniformMatrix4fv(loc.mvp, false, camera(canvas.width / canvas.height, pitch, yaw));\n    const mag = follow.checked ? 1 / scales(s).radial : 1;\n    gl.uniform1f(loc.zoom, 1); gl.uniform1f(loc.point, Math.min(2, dpr * 1.3));\n    // Reference ticks are in view units; the label converts back to world units.\n    const axes = [];\n    function line(a, b) { for (const p of [a, b]) axes.push(...p, 1, 0.13); }\n    line([0,-2.7,0], [0,2.7,0]);\n    for (let i = -3; i <= 3; i++) {\n      line([i*0.5,-2.5,-1.5], [i*0.5,-2.5,1.5]);\n      line([-1.5,-2.5,i*0.5], [1.5,-2.5,i*0.5]);\n    }\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(axes), gl.DYNAMIC_DRAW);\n    gl.drawArrays(gl.LINES, 0, axes.length / 5);\n    if (dirty) rebuild();\n    gl.uniform1f(loc.zoom, mag);\n    gl.bufferData(gl.ARRAY_BUFFER, vertices.subarray(0, vertexCount * 5), gl.DYNAMIC_DRAW);\n    gl.drawArrays(gl.LINES, 0, lineCount);\n    gl.drawArrays(gl.POINTS, lineCount, vertexCount - lineCount);\n    timeText.textContent = \"1−t/T* = \" + tau(s).toExponential(2);\n    zoomText.textContent = (follow.checked ? \"FOLLOW · \" : \"FIXED · \") + mag.toExponential(2) + \"×\";\n    ruler.textContent = \"grid Δ = \" + (0.5 / mag).toExponential(1) + \" initial units\";\n    slider.value = String(s);\n    slider.setAttribute(\"aria-valuetext\", \"s \" + s.toFixed(2) + \", remaining time \" + tau(s).toExponential(2));\n    play.textContent = s >= MAX_S ? \"Replay\" : playing ? \"Pause\" : \"Play\";\n    play.setAttribute(\"aria-pressed\", String(playing));\n  }\n  function wake() { if (!raf && !lost && !document.hidden) raf = requestAnimationFrame(frame); }\n  function frame(now) {\n    raf = 0;\n    const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;\n    last = now;\n    if (playing) { s = Math.min(MAX_S, s + dt * 0.24 * PARAMS.speed); dirty = true; if (s >= MAX_S) playing = false; }\n    if (auto) yaw += dt * 0.09;\n    const start = performance.now();\n    draw();\n    // Reduce geometry only after sustained expensive frames, never change seeds.\n    slow = performance.now() - start > 24 ? slow + 1 : 0;\n    if (slow > 45 && count > 480) { count = 480; dirty = true; slow = 0; }\n    if (playing || auto) wake(); else last = 0;\n  }\n  play.addEventListener(\"click\", function () {\n    if (s >= MAX_S) { s = 0.6; dirty = true; }\n    playing = !playing; last = 0; wake();\n  });\n  slider.addEventListener(\"input\", function () {\n    s = Math.max(0, Math.min(MAX_S, Number(slider.value))); playing = false; dirty = true; last = 0; wake();\n  });\n  follow.addEventListener(\"change\", wake);\n  reset.addEventListener(\"click\", function () {\n    s = motion.matches ? 6 : 0.6; yaw = 0.7; pitch = 0.34; count = COUNT; slow = 0;\n    playing = !motion.matches; auto = !motion.matches; follow.checked = true; pointer = null; dirty = true; last = 0; wake();\n  });\n  canvas.addEventListener(\"pointerdown\", function (e) {\n    if (pointer !== null) return;\n    pointer = e.pointerId; lastX = e.clientX; lastY = e.clientY;\n    canvas.setPointerCapture(e.pointerId);\n  });\n  canvas.addEventListener(\"pointermove\", function (e) {\n    if (e.pointerId !== pointer) return;\n    auto = false; yaw += (e.clientX - lastX) * 0.008;\n    pitch = Math.max(-1.2, Math.min(1.2, pitch + (e.clientY - lastY) * 0.008));\n    lastX = e.clientX; lastY = e.clientY; wake();\n  });\n  for (const name of [\"pointerup\", \"pointercancel\", \"lostpointercapture\"]) canvas.addEventListener(name, function () { pointer = null; });\n  motion.addEventListener(\"change\", function () { if (motion.matches) { playing = false; auto = false; } wake(); });\n  document.addEventListener(\"visibilitychange\", function () {\n    cancelAnimationFrame(raf); raf = 0; last = 0; if (!document.hidden) wake();\n  });\n  new ResizeObserver(wake).observe(document.getElementById(\"scene\"));\n  canvas.addEventListener(\"webglcontextlost\", function (e) {\n    e.preventDefault(); lost = true; cancelAnimationFrame(raf); raf = 0;\n    fail(\"WebGL context lost. Waiting for the browser to restore it.\");\n  });\n  canvas.addEventListener(\"webglcontextrestored\", function () {\n    try {\n      setup(); lost = false; dirty = true; last = 0; fallback.hidden = true;\n      for (const el of [play, slider, follow, reset]) el.disabled = false;\n      wake();\n    } catch (err) { fail(err.message); }\n  });\n  wake();\n})();\n</script>\n</body>\n</html>\n"}