If you're reading this on my portfolio site, you've already seen the result — the softly glowing particle field that drifts behind every page. What looks like a simple background effect is actually a GPU-powered simulation running custom GLSL shaders, Frame Buffer Objects (FBOs), and a carefully tuned post-processing pipeline. Let me walk you through how it works.
Why Three.js + React Three Fiber?
Raw Three.js gives you incredible control over WebGL, but managing a 3D scene imperatively alongside a React application creates a maintenance nightmare. React Three Fiber (R3F) solves this by letting you declare your 3D scene as JSX components. The scene graph becomes part of your React tree, complete with state management, hooks, and component lifecycle. It's not a wrapper or abstraction layer that hides Three.js — it IS Three.js, expressed declaratively.
// A Three.js scene as a React component
import { Canvas } from '@react-three/fiber';
import { Particles } from './particles';
import { VignetteShader } from './shaders/vignetteShader';
export function Scene() {
return (
<Canvas
camera={{ position: [0, 0, 3.5], fov: 50 }}
gl={{ antialias: false, alpha: true }}
>
<Particles />
<VignetteShader />
</Canvas>
);
}The FBO Simulation Pattern
The particle system on this site uses a technique called Frame Buffer Object (FBO) simulation. Instead of updating particle positions on the CPU (which would be impossibly slow for thousands of particles), we store position data in a texture and update it entirely on the GPU using a simulation shader. Each pixel in the texture represents one particle's position. Every frame, the GPU reads the current positions, applies physics (curl noise, in our case), and writes the new positions to a new texture. The render shader then reads those positions to place points in 3D space.
This is the same technique used in high-end demoscene productions and AAA game particle effects. The beauty of it is that you can simulate 100,000+ particles at 60fps because the GPU is doing all the heavy lifting in parallel.
Writing Custom GLSL Shaders
The visual character of the particles — the soft glow, the sparkle, the way they fade at the edges — all comes from a custom fragment shader. GLSL (OpenGL Shading Language) runs directly on the GPU and lets you control exactly how each pixel is drawn. Here's a simplified version of the core rendering logic:
// Fragment shader — controls each particle's appearance
void main() {
// Signed distance field for a circle
float d = sdCircle(gl_PointCoord.xy - 0.5, 0.5);
if (d > 0.0) discard; // Outside the circle
// Sparkle effect using periodic noise
float sparkle = periodicNoise(vPosition * 50.0, uTime);
float brightness = mix(0.7, 2.0, sparkle);
// Distance-based fade
float alpha = smoothstep(3.5, 0.0, vDistance);
alpha *= smoothstep(-1.0, 0.0, vPosY);
gl_FragColor = vec4(uColor * brightness, alpha);
}Performance Considerations
Running a WebGL scene alongside a content-heavy website requires careful performance management. A few things I learned the hard way:
- Always use antialias: false for fullscreen effects — the performance cost isn't worth it for particles
- Set the canvas to position: fixed and pointer-events: none so it doesn't interfere with scrolling or click events
- Use NormalBlending instead of AdditiveBlending for dark backgrounds — additive blending makes particles look washed out
- Reduce the pixel ratio on mobile devices with Math.min(window.devicePixelRatio, 2)
- Pause the animation loop when the tab isn't visible using document.visibilityState
The Vignette Post-Processing Pass
A subtle but important detail is the vignette effect — the gentle darkening around the edges of the viewport. This is achieved with a full-screen post-processing shader that reads the rendered scene texture and multiplies it by a radial gradient. It gives the scene a cinematic feel and naturally draws focus toward the center where the content lives.
The best 3D web experiences are the ones you don't consciously notice. They create atmosphere without demanding attention.
If you want to experiment with this yourself, I'd recommend starting with the React Three Fiber documentation and the drei helper library. The FBO pattern I described is available as useFBO in drei, which handles all the boilerplate of creating render targets and ping-pong buffers. Start simple — a few hundred particles with basic curl noise — and layer complexity from there.