12,000 PARTICLES / — FPS
Move your pointer to touch the stars
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