P

Stellar Flow

LIVE PREVIEW01 / STELLAR

12,000 PARTICLES / — FPS

Move your pointer to touch the stars

Export code

Standalone interactive HTML with the current Stellar Flow settings. It contains the complete renderer and can be hosted as a single file.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Particleify Stellar Flow</title>
  <style>
    * { box-sizing: border-box; }
    html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
    body { background: #03090e; }
    canvas { display: block; width: 100%; height: 100%; touch-action: none; }
    #error {
      position: fixed; left: 50%; bottom: 24px; z-index: 10; display: none;
      width: min(440px, calc(100% - 32px)); transform: translateX(-50%);
      border: 1px solid rgba(252, 211, 77, .24); background: rgba(36, 32, 25, .96);
      padding: 14px 18px; color: #fef3c7; font: 13px/1.6 ui-monospace, monospace;
    }
  </style>
</head>
<body>
  <canvas id="particle-canvas" role="img" aria-label="Flowing particle effect"></canvas>
  <div id="error" role="alert"></div>
  <script>

const CONFIG = {"mode":"six","text":"HELLO","image":"","speed":1,"size":1.3,"count":12000,"scale":1,"scatter":0.16,"glow":0.8,"depth":0.5,"interaction":0.65,"brilliance":1.3,"starDensity":7,"twinkle":0.7,"flowSpeed":1,"starRays":1,"rayLength":1,"coreGlow":1,"rotation":0,"color":"#a9dbff","accentColor":"#ffa66b","background":"#03090e","imageColors":true,"invert":false,"threshold":90,"paused":false};

const canvas = document.getElementById('particle-canvas');
const errorBox = document.getElementById('error');
document.body.style.background = CONFIG.background;

function showError(message) {
  errorBox.textContent = message;
  errorBox.style.display = message ? 'block' : 'none';
}

var SF_PARTICLE_VERTEX_SHADER = "\nprecision highp float;\nattribute vec3 aFrom;\nattribute vec3 aTarget;\nattribute vec3 aColor;\nattribute vec3 aSeed;\nattribute vec3 aActivity;\nattribute vec3 aRayMask;\nuniform vec2 uResolution;\nuniform vec2 uPointer;\nuniform float uTime, uMorph, uSize, uScale, uScatter, uDepth, uInteraction, uRotation, uDpr, uIntro, uMaxPoint;\nuniform float uBrilliance, uStarDensity, uTwinkle, uFootprint;\nuniform float uDensityScale;\nuniform mediump float uStarRays, uRayLength;\nuniform float uCoreGlow, uOrbit;\nvarying vec3 vColor;\nvarying float vBrightness;\nvarying float vStar;\nvarying float vExtent;\nvoid main(){\n  float m=smoothstep(0.,1.,uMorph);\n  vec3 p=mix(aFrom,aTarget,m);\n  float phase=aSeed.x*62.83;\n  float time=uTime*(.32+aSeed.y*.35);\n  vec3 motion=vec3(sin(time+phase),cos(time*.81+phase*1.3),sin(time*.6+phase*2.));\n  p+=motion*(.001+uScatter*mix(.035,.006,uOrbit));\n  p+=motion*sin(m*3.14159)*.13;\n  p=mix(vec3(sin(phase)*aSeed.y*1.3,cos(phase)*aSeed.z, sin(phase*2.)*.7),p,smoothstep(0.,1.,uIntro));\n  float c=cos(uRotation),s=sin(uRotation);\n  p.xy=mat2(c,s,-s,c)*p.xy*uScale;\n  vec2 delta=p.xy-uPointer;\n  float dist=length(delta);\n  p.xy+=delta/max(dist,.001)*exp(-dist*dist/0.011)*uInteraction*.05;\n  p.xy+=uPointer*p.z*uDepth*.3*(1.-step(5.,uPointer.x));\n  p.z*=uDepth;\n  float perspective=1./(1.+p.z*.7);\n  vec2 clip=p.xy*perspective*2.;\n  clip.x/=uResolution.x/uResolution.y;\n  gl_Position=vec4(clip.x,-clip.y,0.,1.);\n  float bright=step(1.-uStarDensity*.01*uDensityScale,aSeed.z);\n  float beacon=step(1.-uStarDensity*.0025*uDensityScale,aSeed.z)*bright;\n  float wave=.5+.5*sin(uTime*(.6+aSeed.y*.8)+phase);\n  float flare=pow(wave,7.)*uTwinkle;\n  float shimmer=1.-uTwinkle*.3+uTwinkle*.3*sin(uTime*(.85+aSeed.y)+phase*1.7);\n  float diameter=mix(2.8+aSeed.y*2.5,21.+aSeed.y*13.,bright);\n  diameter=mix(diameter,42.+aSeed.y*24.,beacon);\n  diameter*=mix(1.,aActivity.y,uOrbit*.6);\n  float crossStar=beacon*aRayMask.x;\n  vExtent=1.+crossStar*uRayLength*.8*step(.01,uStarRays);\n  float screenScale=clamp(min(uResolution.x,uResolution.y)/850.,.38,1.);\n  gl_PointSize=min(uMaxPoint,diameter*(1.+flare*.14*bright)*uSize*uDpr*perspective*screenScale*uFootprint*vExtent);\n  if(aSeed.z>1.5){gl_PointSize=min(uMaxPoint,280.*uDpr*uSize*screenScale);vExtent=1.;}\n  vStar=bright+beacon+step(1.5,aSeed.z);\n  vBrightness=mix(.35+aSeed.y*.65,.65+aSeed.y*.45,bright)*shimmer;\n  vBrightness*=mix(1.,uBrilliance,bright)*(1.+flare*.7*bright);\n  vBrightness*=mix(1.,aActivity.x,uOrbit);\n  vBrightness*=1.+uCoreGlow*aActivity.z*.18*uOrbit;\n  if(aSeed.z>1.5)vBrightness=uCoreGlow*(.6+.4*smoothstep(.25,1.,uIntro))*(.94+.06*sin(uTime*.65));\n  vColor=aColor;\n  if(length(aColor)\u003c.01) vColor=vec3(-1.,aSeed.x,bright);\n}";
var SF_PARTICLE_FRAGMENT_SHADER = "\nprecision mediump float;\nuniform vec3 uColor, uAccent;\nuniform float uGlow;\nuniform float uStarRays, uRayLength;\nvarying vec3 vColor;\nvarying float vBrightness;\nvarying float vStar;\nvarying float vExtent;\nvoid main(){\n  vec2 q=(gl_PointCoord-.5)*2.*vExtent;\n  float r=length(q);\n  if(r>vExtent)discard;\n  float bright=step(.5,vStar),beacon=step(1.5,vStar);\n  float core=mix(exp(-r*r*18.),1.-smoothstep(.025,.12,r),bright);\n  float corona=exp(-r*r*95.)*.4*bright;\n  float halo=(exp(-r*r*9.)*.21+exp(-r*r*3.)*.06)*uGlow*bright;\n  vec2 ray=q/(max(uRayLength,.3)*.7);\n  float horizontal=exp(-abs(q.y)*95.)*(1.-smoothstep(.08,1.35,abs(ray.x)));\n  float vertical=exp(-abs(q.x)*95.)*(1.-smoothstep(.08,1.35,abs(ray.y)));\n  float rays=max(horizontal,vertical)*.4*uStarRays*beacon*step(1.001,vExtent);\n  vec3 tint=vColor.x\u003c0.?mix(uColor,uAccent,step(.77,vColor.y)):vColor;\n  vec3 coreColor=vColor.x\u003c0.?mix(tint,vec3(1.),bright*.83):tint;\n  vec3 energy=coreColor*core+tint*(corona+halo+rays);\n  if(vStar>2.5)energy=mix(tint,vec3(1.),.75)*exp(-r*r*5.)*.5*uGlow;\n  // Premultiply radiance: additive blending preserves HDR-like overlapping\n  // halos without clipping every individual star to a flat white square.\n  gl_FragColor=vec4(energy*vBrightness,1.);\n}";
var SF_BLOOM_VERTEX_SHADER = "attribute vec2 aPosition;varying vec2 vUv;void main(){vUv=(aPosition+1.)*.5;gl_Position=vec4(aPosition,0.,1.);}";
var SF_BLOOM_FRAGMENT_SHADER = "precision mediump float;\nvarying vec2 vUv;uniform sampler2D uSource;uniform sampler2D uHalo;\nuniform vec2 uStep;uniform vec3 uBackground;uniform float uPass,uStrength;\nvec3 readLight(vec2 uv){return texture2D(uSource,uv).rgb;}\nvoid main(){\n if(uPass>2.5){vec3 c=texture2D(uSource,vUv).rgb+texture2D(uHalo,vUv).rgb*uStrength;\n gl_FragColor=vec4(uBackground+c,1.);return;}\n if(uPass\u003c.5){\n vec3 c=(readLight(vUv+uStep*vec2(-.5,-.5))+readLight(vUv+uStep*vec2(.5,-.5))\n +readLight(vUv+uStep*vec2(-.5,.5))+readLight(vUv+uStep*vec2(.5,.5)))*.25;\n c*=smoothstep(.16,.65,max(c.r,max(c.g,c.b)));gl_FragColor=vec4(c,1.);return;}\n vec3 c=readLight(vUv)*.227027;\n c+=(readLight(vUv+uStep*1.384615)+readLight(vUv-uStep*1.384615))*.316216;\n c+=(readLight(vUv+uStep*3.230769)+readLight(vUv-uStep*3.230769))*.070270;\n gl_FragColor=vec4(c,1.);\n}";
var SF_TRACKS = [[[[128,2],[65,24],[11,93],[10,162]],[[10,162],[9,249],[79,263],[88,263]],[[88,263],[117,263],[136,244],[136,221]],[[136,221],[136,190],[103,194],[103,209]]],[[[225,32],[132,4],[30,76],[30,160]],[[30,160],[30,248],[98,260],[126,248]]],[[[126,215],[125,223],[117,237],[101,236]],[[101,236],[89,236],[76,220],[76,196]],[[76,196],[76,166],[100,143],[132,143]],[[132,143],[172,143],[213,171],[213,231]],[[213,231],[213,276],[170,328],[86,317]]],[[[107,237],[82,240],[61,223],[61,185]],[[61,185],[61,153],[92,123],[132,123]],[[132,123],[173,123],[228,150],[228,225]],[[228,225],[228,287],[168,322],[121,322]],[[121,322],[53,322],[11,265],[2,208]]],[[[115,211],[115,226],[92,227],[92,205]],[[92,205],[92,184],[110,175],[128,175]],[[128,175],[146,175],[174,196],[174,231]],[[174,231],[174,264],[148,282],[134,287]],[[134,287],[119,293],[82,297],[54,269]]]];
var SF_TRACK_SPEEDS = [0.025,0.018,0.021,0.016,0.03];
var SF_TRACK_DEPTH = [0.62,-0.46,0.78,-0.7,0.42];
var SF_TRACK_SAMPLES = 512;

function sfRgb(hex) {
  return [1, 3, 5].map(function (index) {
    return parseInt(hex.slice(index, index + 2), 16) / 255;
  });
}

function sfSeededRandom(seed) {
  return function () {
    seed |= 0;
    seed = (seed + 0x6d2b79f5) | 0;
    var value = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
  };
}

function sfPointAt(curve, t) {
  var segment = Math.min(curve.length - 1, Math.floor(t * curve.length));
  var u = t * curve.length - segment;
  var v = 1 - u;
  var p = curve[segment];
  return [
    v * v * v * p[0][0] + 3 * v * v * u * p[1][0] + 3 * v * u * u * p[2][0] + u * u * u * p[3][0],
    v * v * v * p[0][1] + 3 * v * v * u * p[1][1] + 3 * v * u * u * p[2][1] + u * u * u * p[3][1]
  ];
}

// Each lane gets its own arc-length table. All tables run from the outside
// toward the center, regardless of the order of the authored control points.
function sfBuildTracks(aspect, galaxy) {
  var fit = Math.min(1, aspect / 0.84);
  return SF_TRACKS.map(function (curve, lane) {
    var table = new Float32Array((SF_TRACK_SAMPLES + 1) * 5);
    var raw = [];
    var lengths = [0];
    for (var index = 0; index <= 2048; index += 1) {
      var point = sfPointAt(curve, index / 2048);
      raw.push(point);
      if (index) {
        lengths.push(lengths[index - 1] + Math.hypot(point[0] - raw[index - 1][0], point[1] - raw[index - 1][1]));
      }
    }
    var radial = function (point) { return Math.hypot(point[0] - 115, point[1] - 211); };
    var reverse = radial(raw[0]) < radial(raw[2048]);
    for (var sample = 0; sample <= SF_TRACK_SAMPLES; sample += 1) {
      var fraction = sample / SF_TRACK_SAMPLES;
      var progress = reverse ? 1 - fraction : fraction;
      var distance = progress * lengths[2048];
      var low = 0;
      var high = 2048;
      while (high - low > 1) {
        var middle = (low + high) >> 1;
        if (lengths[middle] < distance) low = middle;
        else high = middle;
      }
      var amount = (distance - lengths[low]) / (lengths[high] - lengths[low] || 1);
      var x = raw[low][0] + (raw[high][0] - raw[low][0]) * amount;
      var y = raw[low][1] + (raw[high][1] - raw[low][1]) * amount;
      if (galaxy) {
        var radius = 0.48 * Math.pow(1 - fraction, 0.78);
        var angle = lane * Math.PI * 2 / 5 + radius * 16;
        table[sample * 5] = Math.cos(angle) * radius * fit;
        table[sample * 5 + 1] = Math.sin(angle) * radius * 0.75 * fit;
      } else {
        table[sample * 5] = (x - 115) / 325 * 0.9 * fit;
        table[sample * 5 + 1] = ((y - 211) / 325 * 0.9 + 0.11) * fit;
      }
      table[sample * 5 + 2] = Math.sin(progress * Math.PI) * Math.sin(progress * Math.PI * 1.35 + lane * 0.82) * SF_TRACK_DEPTH[lane] * 0.09;
    }
    for (var row = 0; row <= SF_TRACK_SAMPLES; row += 1) {
      var a = Math.max(0, row - 1) * 5;
      var b = Math.min(SF_TRACK_SAMPLES, row + 1) * 5;
      var dx = table[b] - table[a];
      var dy = table[b + 1] - table[a + 1];
      var length = Math.hypot(dx, dy) || 1;
      table[row * 5 + 3] = -dy / length;
      table[row * 5 + 4] = dx / length;
    }
    return table;
  });
}

function sfSmooth(a, b, value) {
  var amount = Math.min(1, Math.max(0, (value - a) / (b - a)));
  return amount * amount * (3 - 2 * amount);
}

function sfAdvanceOrbit(state, seconds, points, activity) {
  for (var index = 0; index < state.lanes.length; index += 1) {
    var offset3 = index * 3;
    var lane = state.lanes[index];
    activity[offset3] = 1;
    activity[offset3 + 1] = 1;
    activity[offset3 + 2] = 0;
    if (lane === -1 || lane === -3) {
      points[offset3] = state.base[offset3];
      points[offset3 + 1] = state.base[offset3 + 1];
      points[offset3 + 2] = state.base[offset3 + 2];
      continue;
    }
    if (lane === -2) {
      var spin = (state.phases[index] + seconds * 0.045) % 1;
      var radius = 0.045 * Math.pow(1 - spin, 0.7) * state.fit;
      var angle = state.offsets[index * 2] - spin * Math.PI * 4;
      points[offset3] = Math.cos(angle) * radius;
      points[offset3 + 1] = state.centerY + Math.sin(angle) * radius;
      points[offset3 + 2] = state.offsets[index * 2 + 1] * (1 - spin);
      activity[offset3] = sfSmooth(0, 0.06, spin) * (1 - sfSmooth(0.94, 1, spin));
      activity[offset3 + 1] = 0.75;
      activity[offset3 + 2] = 1;
      continue;
    }
    var phase = (state.phases[index] + seconds * SF_TRACK_SPEEDS[lane]) % 1;
    var progress = phase + 0.34 * Math.sin(phase * Math.PI * 2) / (Math.PI * 2);
    var sample = progress * SF_TRACK_SAMPLES;
    var sampleIndex = Math.min(SF_TRACK_SAMPLES - 1, Math.floor(sample));
    var amount = sample - sampleIndex;
    var a = sampleIndex * 5;
    var b = a + 5;
    var table = state.tracks[lane];
    var nx = table[a + 3] + (table[b + 3] - table[a + 3]) * amount;
    var ny = table[a + 4] + (table[b + 4] - table[a + 4]) * amount;
    var laneOffset = state.offsets[index * 2];
    points[offset3] = table[a] + (table[b] - table[a]) * amount + nx * laneOffset;
    points[offset3 + 1] = table[a + 1] + (table[b + 1] - table[a + 1]) * amount + ny * laneOffset;
    points[offset3 + 2] = table[a + 2] + (table[b + 2] - table[a + 2]) * amount + state.offsets[index * 2 + 1];
    activity[offset3] = sfSmooth(0, 0.055, progress) * (1 - sfSmooth(0.945, 1, progress));
    activity[offset3 + 1] = 0.3 + 0.7 * Math.pow(Math.sin(progress * Math.PI), 0.68);
    activity[offset3 + 2] = Math.max(0, 1 - Math.hypot(points[offset3], points[offset3 + 1] - state.centerY) / 0.1);
  }
}

function sfCreateOrbit(count, aspect, random, galaxy) {
  var points = new Float32Array(count * 3);
  var colors = new Float32Array(count * 3);
  var activity = new Float32Array(count * 3);
  var fit = Math.min(1, aspect / 0.84);
  var centerY = galaxy ? 0 : 0.11 * fit;
  var state = {
    tracks: sfBuildTracks(aspect, galaxy),
    lanes: new Int8Array(count),
    phases: new Float32Array(count),
    offsets: new Float32Array(count * 2),
    base: new Float32Array(count * 3),
    centerY: centerY,
    fit: fit
  };
  var lane = 0;
  for (var index = 0; index < count; index += 1) {
    state.phases[index] = random();
    if (index >= count - 4) {
      state.lanes[index] = -3;
      var angle = (index - count + 4) * Math.PI * 2 / 3;
      var radius = index === count - 1 ? 0 : 0.003;
      points.set([Math.cos(angle) * radius, centerY + Math.sin(angle) * radius, 0], index * 3);
    } else if (index % 10 === 0) {
      state.lanes[index] = -1;
      points.set([(random() - 0.5) * aspect * 1.8, (random() - 0.5) * 1.8, (random() - 0.5) * 0.7], index * 3);
    } else if (index % 71 === 1) {
      state.lanes[index] = -2;
      state.offsets[index * 2] = random() * Math.PI * 2;
      state.offsets[index * 2 + 1] = (random() - 0.5) * 0.035;
    } else {
      state.lanes[index] = lane % 5;
      lane += 1;
      state.offsets[index * 2] = (random() + random() - 1) * 0.01 * fit;
      state.offsets[index * 2 + 1] = (random() + random() - 1) * 0.02;
    }
  }
  state.base.set(points);
  sfAdvanceOrbit(state, 0, points, activity);
  return { points: points, colors: colors, activity: activity, orbit: state };
}

function sfLoadImage(src, signal, standalone) {
  var image = new Image();
  image.crossOrigin = 'anonymous';
  return new Promise(function (resolve, reject) {
    var cancel = function () {
      cleanup();
      image.src = '';
      reject(new DOMException('Aborted', 'AbortError'));
    };
    var timeout = window.setTimeout(function () {
      cleanup();
      image.src = '';
      reject(new Error(standalone
        ? 'Image loading timed out. Try another image.'
        : 'Image loading timed out. Try uploading a local image.'));
    }, 15000);
    var cleanup = function () {
      window.clearTimeout(timeout);
      signal.removeEventListener('abort', cancel);
    };
    image.onload = function () { cleanup(); resolve(image); };
    image.onerror = function () {
      cleanup();
      reject(new Error(standalone
        ? 'Could not read this image. Its URL must allow cross-origin access.'
        : 'Could not read this image. Its URL must allow cross-origin access; try uploading a local image.'));
    };
    signal.addEventListener('abort', cancel, { once: true });
    if (signal.aborted) cancel();
    else image.src = src;
  });
}

async function sfMakeTargets(config, aspect, signal, standalone) {
  var random = sfSeededRandom(73124);
  if (config.mode === 'six' || config.mode === 'galaxy') {
    return sfCreateOrbit(config.count, aspect, random, config.mode === 'galaxy');
  }
  var points = new Float32Array(config.count * 3);
  var colors = new Float32Array(config.count * 3);
  var samples = [];
  var mask = document.createElement('canvas');
  mask.width = 900;
  mask.height = 600;
  var context = mask.getContext('2d', { willReadFrequently: true });
  if (!context) throw new Error('Could not create the image sampling canvas.');

  if (config.mode === 'text') {
    var lines = (config.text.trim() || 'HELLO').split('\n').slice(0, 4);
    context.font = '700 180px Arial, "PingFang SC", sans-serif';
    var widest = Math.max.apply(null, lines.map(function (line) { return context.measureText(line).width; }));
    var fontSize = Math.min(210, 790 / Math.max(widest, 1) * 180, 460 / lines.length);
    context.font = '700 ' + fontSize + 'px Arial, "PingFang SC", sans-serif';
    context.textAlign = 'center';
    context.textBaseline = 'middle';
    context.fillStyle = '#ffffff';
    lines.forEach(function (line, index) {
      context.fillText(line, 450, 300 + (index - (lines.length - 1) / 2) * fontSize * 1.12);
    });
  } else {
    if (!config.image) throw new Error('Choose an image or enter an image URL.');
    var image = await sfLoadImage(config.image, signal, standalone);
    if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
    var ratio = Math.min(820 / image.width, 520 / image.height);
    context.drawImage(image, (900 - image.width * ratio) / 2, (600 - image.height * ratio) / 2, image.width * ratio, image.height * ratio);
  }

  var pixels;
  try {
    pixels = context.getImageData(0, 0, 900, 600).data;
  } catch (error) {
    throw new Error(standalone
      ? 'This image blocks cross-origin sampling. Download it and use a local or CORS-enabled image.'
      : 'This image blocks cross-origin sampling. Download it and upload the local file.');
  }
  var fit = Math.min(1, aspect / 1.55);
  for (var y = 0; y < 600; y += 2) {
    for (var x = 0; x < 900; x += 2) {
      var pixel = (y * 900 + x) * 4;
      var luminance = pixels[pixel] * 0.2126 + pixels[pixel + 1] * 0.7152 + pixels[pixel + 2] * 0.0722;
      if (pixels[pixel + 3] > 50 && (config.mode === 'text' || (config.invert ? 255 - luminance : luminance) > config.threshold)) {
        samples.push([
          (x / 900 - 0.5) * 1.38 * fit,
          (y / 600 - 0.5) * 0.92 * fit,
          pixels[pixel] / 255,
          pixels[pixel + 1] / 255,
          pixels[pixel + 2] / 255
        ]);
      }
    }
  }
  if (!samples.length) throw new Error('No particles found. Lower the brightness threshold or toggle Invert brightness.');

  for (var index = 0; index < config.count; index += 1) {
    var px = 0;
    var py = 0;
    var pz = 0;
    if (index % 10 === 0) {
      px = (random() - 0.5) * aspect * 1.8;
      py = (random() - 0.5) * 1.8;
      pz = (random() - 0.5) * 0.9;
    } else {
      var point = samples[Math.floor(random() * samples.length)];
      px = point[0] + (random() - 0.5) * 0.002;
      py = point[1] + (random() - 0.5) * 0.002;
      pz = (random() - 0.5) * 0.05;
      if (config.mode === 'image' && config.imageColors) colors.set(point.slice(2), index * 3);
    }
    points.set([px, py, pz], index * 3);
  }
  return { points: points, colors: colors, activity: new Float32Array(config.count * 3).fill(1), orbit: null };
}

function sfCompileShader(gl, type, source) {
  var shader = gl.createShader(type);
  if (!shader) throw new Error('Could not create a WebGL shader.');
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    var message = gl.getShaderInfoLog(shader) || 'Shader compilation failed.';
    gl.deleteShader(shader);
    throw new Error(message);
  }
  return shader;
}

// Separate screen-space glow lets small sharp stars illuminate their neighbors.
function sfCreateBloom(gl) {
  var shaders = [];
  var textures = [];
  var frames = [];
  var program = gl.createProgram();
  var quad = gl.createBuffer();
  if (!program || !quad) throw new Error('Could not create the particle glow renderer.');
  var dispose = function () {
    shaders.forEach(function (shader) { gl.deleteShader(shader); });
    textures.forEach(function (texture) { gl.deleteTexture(texture); });
    frames.forEach(function (frame) { gl.deleteFramebuffer(frame); });
    gl.deleteBuffer(quad);
    gl.deleteProgram(program);
  };
  try {
    [[gl.VERTEX_SHADER, SF_BLOOM_VERTEX_SHADER], [gl.FRAGMENT_SHADER, SF_BLOOM_FRAGMENT_SHADER]].forEach(function (entry) {
      var shader = sfCompileShader(gl, entry[0], entry[1]);
      shaders.push(shader);
      gl.attachShader(program, shader);
    });
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error('Glow shader link failed.');
    for (var index = 0; index < 3; index += 1) {
      var texture = gl.createTexture();
      var frame = gl.createFramebuffer();
      if (!texture || !frame) throw new Error('Could not allocate the particle glow canvas.');
      textures.push(texture);
      frames.push(frame);
    }
    gl.bindBuffer(gl.ARRAY_BUFFER, quad);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
  } catch (error) {
    dispose();
    throw error;
  }
  var position = gl.getAttribLocation(program, 'aPosition');
  var uniforms = {};
  ['Source', 'Halo', 'Step', 'Background', 'Pass', 'Strength'].forEach(function (name) {
    uniforms[name] = gl.getUniformLocation(program, 'u' + name);
  });
  var width = 1;
  var height = 1;
  var bloomWidth = 1;
  var bloomHeight = 1;
  return {
    resize: function (nextWidth, nextHeight) {
      width = nextWidth;
      height = nextHeight;
      bloomWidth = Math.max(1, Math.ceil(nextWidth / 2));
      bloomHeight = Math.max(1, Math.ceil(nextHeight / 2));
      for (var index = 0; index < 3; index += 1) {
        gl.bindTexture(gl.TEXTURE_2D, textures[index]);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, index ? bloomWidth : width, index ? bloomHeight : height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
        gl.bindFramebuffer(gl.FRAMEBUFFER, frames[index]);
        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, textures[index], 0);
        if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) throw new Error('Could not allocate the particle glow canvas.');
      }
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    },
    begin: function () {
      gl.bindFramebuffer(gl.FRAMEBUFFER, frames[0]);
      gl.viewport(0, 0, width, height);
      gl.enable(gl.BLEND);
      gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
      gl.clearColor(0, 0, 0, 1);
      gl.clear(gl.COLOR_BUFFER_BIT);
    },
    end: function (background, strength) {
      gl.disable(gl.BLEND);
      gl.useProgram(program);
      gl.bindBuffer(gl.ARRAY_BUFFER, quad);
      gl.enableVertexAttribArray(position);
      gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
      gl.uniform1i(uniforms.Source, 0);
      gl.uniform1i(uniforms.Halo, 1);
      gl.uniform3fv(uniforms.Background, background);
      gl.uniform1f(uniforms.Strength, strength * 1.5);
      gl.activeTexture(gl.TEXTURE1);
      gl.bindTexture(gl.TEXTURE_2D, textures[0]);
      for (var pass = 0; pass < 4; pass += 1) {
        gl.uniform1f(uniforms.Pass, pass);
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, textures[pass === 1 ? 1 : pass === 2 ? 2 : 0]);
        gl.bindFramebuffer(gl.FRAMEBUFFER, pass === 3 ? null : frames[pass === 1 ? 2 : 1]);
        gl.viewport(0, 0, pass === 3 ? width : bloomWidth, pass === 3 ? height : bloomHeight);
        // These paired Gaussian taps assume adjacent texels. Multiplying their
        // spacing creates separate copies of a point, then a grid after pass 2.
        // Downsample first so both blur axes use the same texel spacing.
        gl.uniform2f(
          uniforms.Step,
          pass === 0 ? 1 / width : pass === 1 ? 1 / bloomWidth : 0,
          pass === 0 ? 1 / height : pass === 2 ? 1 / bloomHeight : 0
        );
        if (pass === 3) {
          gl.activeTexture(gl.TEXTURE1);
          gl.bindTexture(gl.TEXTURE_2D, textures[1]);
        }
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      }
      gl.activeTexture(gl.TEXTURE0);
    },
    dispose: dispose
  };
}

function createSceneRuntime(THREE, canvas, initialConfig, host) {
  host = host || {};
  var standalone = !!host.standalone;
  var config = initialConfig;
  var disposed = false;
  var frame = 0;
  var abort = new AbortController();
  var report = function (message) {
    if (!disposed && host.onError) host.onError(message);
  };
  var noop = {
    update: function (next) { config = next; },
    setPaused: function () {},
    setQualityLocked: function () {},
    replay: function () {},
    dispose: function () { disposed = true; }
  };

  var gl = canvas.getContext('webgl', {
    alpha: false,
    antialias: false,
    preserveDrawingBuffer: true,
    powerPreference: 'high-performance'
  });
  if (!gl) {
    report('WebGL is unavailable. Enable hardware acceleration and try again.');
    return noop;
  }
  var program = gl.createProgram();
  var shaders = [];
  try {
    shaders.push(
      sfCompileShader(gl, gl.VERTEX_SHADER, SF_PARTICLE_VERTEX_SHADER),
      sfCompileShader(gl, gl.FRAGMENT_SHADER, SF_PARTICLE_FRAGMENT_SHADER)
    );
    shaders.forEach(function (shader) { gl.attachShader(program, shader); });
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      throw new Error(gl.getProgramInfoLog(program) || 'Could not link particle renderer');
    }
  } catch (error) {
    report(error.message);
    shaders.forEach(function (shader) { gl.deleteShader(shader); });
    gl.deleteProgram(program);
    return noop;
  }
  var bloom;
  try {
    bloom = sfCreateBloom(gl);
  } catch (error) {
    report(error.message);
    shaders.forEach(function (shader) { gl.deleteShader(shader); });
    gl.deleteProgram(program);
    return noop;
  }
  gl.useProgram(program);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE);

  var uniforms = {};
  ['Resolution', 'Pointer', 'Time', 'Morph', 'Size', 'Scale', 'Scatter', 'Depth', 'Interaction', 'Rotation', 'Dpr', 'Intro', 'Color', 'Accent', 'Glow', 'MaxPoint', 'Brilliance', 'StarDensity', 'Twinkle', 'Footprint', 'StarRays', 'RayLength', 'CoreGlow', 'Orbit', 'DensityScale'].forEach(function (name) {
    uniforms[name] = gl.getUniformLocation(program, 'u' + name);
  });
  var buffers = {};
  var attributes = {};
  ['From', 'Target', 'Color', 'Seed', 'Activity', 'RayMask'].forEach(function (name) {
    buffers[name] = gl.createBuffer();
    attributes[name] = gl.getAttribLocation(program, 'a' + name);
  });
  var bindAttributes = function () {
    Object.keys(buffers).forEach(function (name) {
      gl.bindBuffer(gl.ARRAY_BUFFER, buffers[name]);
      gl.enableVertexAttribArray(attributes[name]);
      gl.vertexAttribPointer(attributes[name], 3, gl.FLOAT, false, 0, 0);
    });
  };
  var upload = function (name, data) {
    gl.bindBuffer(gl.ARRAY_BUFFER, buffers[name]);
    gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
  };

  var w = 1;
  var h = 1;
  var dpr = 1;
  var n = 0;
  var time = 0;
  var morph = 1;
  var intro = config.paused ? 1 : 0;
  var last = 0;
  var statsAt = 0;
  var frames = 0;
  var visible = true;
  var from = new Float32Array(0);
  var target = new Float32Array(0);
  var activity = new Float32Array(0);
  var orbit = null;
  var flowTime = 0;
  var pointer = [10, 10];
  var smoothed = [0, 0];
  var builtKey = '';
  var reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
  var maxPoint = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE)[1];

  function shapeKey(c) {
    return [c.mode, c.text, c.image, c.count, c.imageColors, c.invert, c.threshold].join('|');
  }

  var rebuild = async function () {
    abort.abort();
    abort = new AbortController();
    var signal = abort.signal;
    builtKey = shapeKey(config);
    try {
      var result = await sfMakeTargets(config, w / h, signal, standalone);
      if (disposed || signal.aborted) return;
      var nextN = result.points.length / 3;
      var nextFrom = new Float32Array(result.points.length);
      var m = morph * morph * (3 - 2 * morph);
      // Morph from wherever the previous shape currently is.
      for (var i = 0; i < nextFrom.length; i += 1) {
        nextFrom[i] = target.length
          ? from[i % from.length] * (1 - m) + target[i % target.length] * m
          : result.points[i];
      }
      var hadPoints = n > 0;
      from = nextFrom;
      target = result.points;
      activity = result.activity;
      orbit = result.orbit;
      flowTime = 0;
      n = nextN;
      // The editor morphs between shapes; exports (fixed settings) snap, as they always have.
      morph = hadPoints && !standalone ? 0 : 1;
      var seeds = new Float32Array(n * 3);
      for (var s = 0; s < n * 3; s += 1) seeds[s] = (((Math.sin(s * 127.1 + 311.7) * 43758.5453) % 1) + 1) % 1;
      // Background dust stays dim; the luminous stars belong to the shape.
      for (var d = 0; d < n; d += 10) if (d % 300 !== 0) seeds[d * 3 + 2] *= 0.83;
      if (orbit) {
        for (var c = n - 4; c < n - 1; c += 1) seeds.set([0.4, 0.85, 0.9999], c * 3);
        seeds[n * 3 - 1] = 2;
      }
      // One stable optical source per lane, never the central cluster or
      // background dust. Static image/text shapes use at most three sources.
      var rayMask = new Float32Array(n * 3);
      var candidates = new Array(orbit ? 5 : 3).fill(-1);
      for (var p = 0; p < n; p += 1) {
        var lane = orbit ? orbit.lanes[p] : p % 3;
        if (lane < 0 || p % 10 === 0) continue;
        var previous = candidates[lane];
        if (previous < 0 || seeds[p * 3 + 2] > seeds[previous * 3 + 2]) candidates[lane] = p;
      }
      candidates.forEach(function (index) { if (index >= 0) rayMask[index * 3] = 1; });
      upload('From', from);
      upload('Target', target);
      upload('Color', result.colors);
      upload('Seed', seeds);
      upload('Activity', activity);
      upload('RayMask', rayMask);
      report('');
    } catch (error) {
      if (error && error.name !== 'AbortError') {
        report(error instanceof Error ? error.message : 'Could not create the particle scene.');
      }
    }
  };

  var resize = function () {
    var bounds = canvas.getBoundingClientRect();
    var oldAspect = w / h;
    w = Math.max(1, bounds.width);
    h = Math.max(1, bounds.height);
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.round(w * dpr);
    canvas.height = Math.round(h * dpr);
    try {
      bloom.resize(canvas.width, canvas.height);
    } catch (error) {
      report(error.message);
      return;
    }
    if (Math.abs(oldAspect - w / h) > 0.025 || !n) rebuild();
  };
  var resizeObserver = new ResizeObserver(resize);
  resizeObserver.observe(canvas);
  // Skip drawing while the canvas is scrolled away (cards, embedded exports).
  var intersection = new IntersectionObserver(function (entries) {
    visible = entries[0].isIntersecting;
  });
  intersection.observe(canvas);
  var move = function (event) {
    var bounds = canvas.getBoundingClientRect();
    pointer = [(event.clientX - bounds.left - w / 2) / h, (event.clientY - bounds.top - h / 2) / h];
  };
  var leave = function () { pointer = [10, 10]; };
  var lost = function (event) {
    event.preventDefault();
    report('The graphics context was interrupted. Refresh the page to restore it.');
  };
  canvas.addEventListener('pointermove', move);
  canvas.addEventListener('pointerleave', leave);
  canvas.addEventListener('webglcontextlost', lost);
  resize();

  var tick = function (now) {
    if (disposed) return;
    frame = requestAnimationFrame(tick);
    var dt = Math.min((now - (last || now)) / 1000, 0.05);
    last = now;
    if (!visible || document.hidden || gl.isContextLost()) return;
    var c = config;
    if (!c.paused && !reduced.matches) {
      time += dt * c.speed;
      flowTime += dt * c.speed * c.flowSpeed;
    }
    if (!c.paused) {
      morph = Math.min(1, morph + dt / 1.5);
      intro = Math.min(1, intro + dt / 2.8);
    }
    if (reduced.matches) {
      intro = 1;
      morph = 1;
    }
    var lerp = 1 - Math.exp(-dt * 4);
    if (!c.paused) {
      smoothed = smoothed.map(function (value, index) {
        return value + ((pointer[index] === 10 ? 0 : pointer[index]) - value) * lerp;
      });
    }
    if (!n) return;
    if (orbit) {
      sfAdvanceOrbit(orbit, flowTime, target, activity);
      gl.bindBuffer(gl.ARRAY_BUFFER, buffers.Target);
      gl.bufferSubData(gl.ARRAY_BUFFER, 0, target);
      gl.bindBuffer(gl.ARRAY_BUFFER, buffers.Activity);
      gl.bufferSubData(gl.ARRAY_BUFFER, 0, activity);
    }
    bloom.begin();
    gl.useProgram(program);
    bindAttributes();
    gl.uniform2f(uniforms.Resolution, w, h);
    gl.uniform2f(uniforms.Pointer, pointer[0] === 10 ? 10 : smoothed[0], pointer[1] === 10 ? 10 : smoothed[1]);
    var values = {
      Time: time,
      Morph: morph,
      Size: c.size,
      Scale: c.scale,
      Scatter: c.scatter,
      Depth: c.depth,
      Interaction: reduced.matches ? 0 : c.interaction,
      Rotation: c.rotation * Math.PI / 180,
      Dpr: dpr,
      Intro: intro,
      Glow: c.glow,
      MaxPoint: maxPoint,
      Brilliance: c.brilliance,
      StarDensity: c.starDensity,
      Twinkle: c.twinkle,
      Footprint: orbit ? 1 : 0.72,
      StarRays: c.starRays,
      RayLength: c.rayLength,
      CoreGlow: c.coreGlow,
      Orbit: orbit ? 1 : 0,
      DensityScale: Math.min(1, (orbit ? 4500 : 6000) / n)
    };
    Object.keys(values).forEach(function (name) { gl.uniform1f(uniforms[name], values[name]); });
    gl.uniform3fv(uniforms.Color, sfRgb(c.color));
    gl.uniform3fv(uniforms.Accent, sfRgb(c.accentColor));
    gl.drawArrays(gl.POINTS, 0, n);
    bloom.end(sfRgb(c.background), c.glow);
    frames += 1;
    if (now - statsAt > 1000) {
      if (host.onStats) host.onStats(Math.round((frames * 1000) / (now - statsAt)), n);
      frames = 0;
      statsAt = now;
    }
  };
  frame = requestAnimationFrame(tick);

  return {
    update: function (next) {
      config = next;
      if (shapeKey(config) !== builtKey) rebuild();
    },
    setPaused: function (value) {
      config = Object.assign({}, config, { paused: !!value });
    },
    setQualityLocked: function () {},
    replay: function () {
      intro = 0;
      time = 0;
      flowTime = 0;
    },
    dispose: function () {
      disposed = true;
      abort.abort();
      cancelAnimationFrame(frame);
      resizeObserver.disconnect();
      intersection.disconnect();
      canvas.removeEventListener('pointermove', move);
      canvas.removeEventListener('pointerleave', leave);
      canvas.removeEventListener('webglcontextlost', lost);
      Object.keys(buffers).forEach(function (name) { gl.deleteBuffer(buffers[name]); });
      shaders.forEach(function (shader) { gl.deleteShader(shader); });
      gl.deleteProgram(program);
      bloom.dispose();
    }
  };
}

const runtime = createSceneRuntime(null, canvas, CONFIG, { standalone: true, onError: showError });
window.addEventListener('beforeunload', function () { runtime.dispose(); }, { once: true });

  </script>
</body>
</html>

Render diagnostics