StarViz: Building an Audio-Reactive GLSL Visualizer
I reverse-engineered the dreamy, pulsing visualizer from Peggy Gou's website, building it from the ground up by layering effects in a single fragment shader in GLSL. This tutorial breaks down what I did, step by step.
Before we jump into building our visualizer, let's understand the fundamental building block: the distance function. In shader programming, we calculate the distance from each pixel to a center point. This distance value becomes the foundation for creating radial patterns.
The visualization above shows how distance creates a natural gradient from the center. Brighter areas are closer to the center, darker areas are further away. This simple concept is the basis for all circular patterns in our visualizer.
We'll start with the foundation: one circle. The size is determined by the distance() from the center of the screen, but to create those soft, dreamy edges, we use the smoothstep function.
Basic circle using distance from center
1float dist = distance(uv, center);2float circle = 1.0 - smoothstep(size - 0.04, size + 0.04, dist);
The smoothstep function creates soft edges by interpolating between two threshold values, giving us that smooth, blurred appearance right from the start.
Great, now let's make three concentric ones. Which just means circles that share the exact same center point but have different radii.
Creating three concentric circles
1float circle1 = 1.0 - smoothstep(size - 0.04, size + 0.04, dist);2float circle2 = 1.0 - smoothstep((size + 0.05) - 0.06, (size + 0.05) + 0.06, dist);3float circle3 = 1.0 - smoothstep((size + 0.1) - 0.08, (size + 0.1) + 0.08, dist);
Notice how each successive circle has a larger blur range (0.04, 0.06, 0.08). This creates more diffusion as we move outward, adding to that dreamy aesthetic.
Now for the heartbeat: making it react to audio. We'll work with a 'bass' value as our reactive design approach. I sample low-frequency bass from an audio texture at a specific UV coordinate vec2(0.01, 0.25). This value is smoothed and clamped, then fed into the circle calculations to make them pulse in sync with the music.
Sampling audio data for reactivity
1float bass = texture2D(iChannel0, vec2(0.01, 0.25)).x;2bass = smoothstep(0.2, 0.5, bass) * 0.8;3bass = clamp(bass, 0.0, 0.7);
The smoothstep here helps eliminate noise and creates smooth transitions between audio levels. The clamp ensures we don't get values that are too extreme.
Cool, so the pulse we have right now is flat. Let's add a secondary wave of animation that makes the circles breathe independently for more depth. I create a radial sine wave based on the distance from the center and time. By mixing this wave with the intensity of our circles, you can make them throb and breathe.
Creating a radial breathing effect
1float slowTime = iTime * 0.5;2float waveSpeed = 2.0;3float waveCount = 5.0;4float radialWave = sin(dist * waveCount - slowTime * waveSpeed);5radialWave = 0.5 + 0.5 * radialWave;67circle1 *= mix(1.0, radialWave, 0.3);8circle2 *= mix(1.0, radialWave, 0.2);9circle3 *= mix(1.0, radialWave, 0.1);
Each circle receives a different amount of wave influence, creating a layered, organic breathing effect.
Just to make this design our own, we're going to turn it into a starburst shape. So using polar coordinates I calculate the angle from the center using atan(), then apply a high-frequency sine wave (frequency ~20). This warps the circles, creating the spikes.
Transforming circles into a starburst
1float angle = atan(uv.y - center.y, uv.x - center.x);2float waveIntensity = 0.03;3float waveFrequency = 20.0;4float wave = waveIntensity * sin(angle * waveFrequency + slowTime * 4.0);56// Add wave to circle calculations7float circle1 = 1.0 - smoothstep(size + wave - 0.04, size + wave + 0.04, dist);
The sine wave warps the radius based on the angle, creating those characteristic star points that pulse and rotate.
Now for the color and its reactivity. We'll create a base color, then layer in influences from our pulses and audio. The gradient is a classic technique: 0.5 + 0.5 * cos(time + vec3(0,2,4) + bass), basically most shader tutorials go through this.
Generating reactive color gradients
1vec3 baseColor = 0.5 + 0.5 * cos(slowTime * 0.5 + vec3(0, 2, 4) + bass * 0.5);2baseColor = mix(baseColor, vec3(1.0), 0.7); // Brighten the colors34float centerPulse = 0.5 + 0.5 * sin(slowTime * 3.0);5float bassPulse = bass * 0.4;6centerPulse = mix(centerPulse, 1.0, bassPulse);78vec3 color = baseColor * (0.9 + 0.1 * centerPulse + bass * 0.1);
This generates a smooth, endlessly evolving gradient palette where the bass subtly shifts the hues. I use the audio and pulse values to control the brightness and intensity of the colors, and add a radial gradient for depth.
A for aesthetic I really want the core to beam bright. So I'm creating a separate glowing core that pulses with the bass. This turns a simple color swap into a dynamic lighting system.
Creating a pulsing glow at the center
1float centerGlow = 1.0 - smoothstep(0.0, 0.1 + bass * 0.05, dist);2color += centerGlow * baseColor * (0.1 + bass * 0.1);
At this point, we have all the core layers working together: the starburst shape, audio reactivity with simulated bass, radial waves, color gradients, and the glowing core. Let's see how it all looks combined:
That looks really cool but just a slight tweak here the dreamy blurred glow requires manual post processing. It's complex so just to keep things honest this was copy paste, but basically for this specific piece how I blur with code is by taking each pixel in the design, creating a 5x5 grid of all of the colors next to it, then blending the results.
Manual blur implementation (simplified)
1vec3 blurred = color;2float blurAmount = 0.004;34for (int i = -2; i <= 2; i++) {5for (int j = -2; j <= 2; j++) {6if (i != 0 || j != 0) {7vec2 offset = vec2(float(i), float(j)) * blurAmount;8vec2 sampleUV = uv + offset;910// Sample and accumulate colors from neighboring pixels11// ... (recalculate the effect at this position)12blurred += sampleColor;13}14}15}1617blurred /= float(samples + 1);18color = mix(color, blurred, 0.25);
Here's a simplified 3x3 blur in action alongside the vignette and gamma correction:
Cooooool. So one last thing: a vignette to darken the edges and gamma correction to make the colors pop. pow(color, 1.0/1.2) adds depth and ensures the colors feel rich and natural.
Adding vignette and gamma correction
1// Vignette2float vignette = 1.0 - smoothstep(0.4, 1.2, length(uv - vec2(0.5 * aspect, 0.5)));3color *= vignette;45// Gamma correction6color = pow(color, vec3(1.0/1.2));
To make this work in the browser, we need to connect the Web Audio API to our shader. Here's how the audio data flows into the visualization:
Setting up audio analysis
1const audioContext = new (window.AudioContext || window.webkitAudioContext)();2const analyser = audioContext.createAnalyser();3analyser.fftSize = 2048;4analyser.smoothingTimeConstant = 0.8;56const audioData = new Uint8Array(analyser.frequencyBinCount);7const source = audioContext.createMediaElementSource(audio);8source.connect(analyser);9analyser.connect(audioContext.destination);
Then we extract frequency data and pass it to the shader as a texture:
Extracting frequency data for shader
1analyser.getByteFrequencyData(audioData);23// Map frequency ranges4const bassRange = audioData.slice(0, 10); // ~0-100Hz5const bassAvg = bassRange.reduce((a, b) => a + b, 0) / bassRange.length / 255;67// Create frequency texture8const frequencyTexture = new Float32Array(256);9frequencyTexture[0] = Math.min(1.0, bassAvg * 2.0);1011// Upload to WebGL12gl.texImage2D(13gl.TEXTURE_2D,140,15gl.LUMINANCE,16256,171,180,19gl.LUMINANCE,20gl.FLOAT,21frequencyTexture22);
The final result is a performant, self-contained shader that transforms any audio input into a living, breathing starlight with 6 layers of animation:
- Base concentric circles - The foundation structure
- Audio reactivity - Bass-driven pulsing
- Radial breathing wave - Secondary organic motion
- Starburst warping - Angular sine wave deformation
- Color gradient cycling - Smooth hue transitions
- Glowing core - Dynamic center lighting
Here's the complete visualization with all layers combined:
This shader can be quite intensive, especially with the blur post-processing. Here are some tips for optimization:
- Reduce blur samples: Use a 3x3 grid instead of 5x5
- Lower resolution: Run at 0.75 DPR or lower on weaker devices
- Simplify audio processing: Reduce FFT size if needed
- Skip expensive operations: The blur is optional for basic functionality
Building StarViz taught me a lot about layering simple effects to create complex visuals. The key is understanding how each layer contributes to the final result and how they interact with each other. Audio reactivity adds that special "alive" quality that makes the visualization feel connected to the music.
The dreamy aesthetic comes from soft edges, gentle pulsing, and that custom blur - but the core technique is accessible to anyone comfortable with basic GLSL. Start simple with one circle, then add each layer one at a time.
Thanks for reading, and I hope this inspires you to create your own audio visualizations!