The magical world of Particles with React Three Fiber and Shaders
November 8, 2022 / 26 min read
Last Updated: November 8, 2022Since writing The Study of Shaders with React Three Fiber, I've continued building new scenes to perfect my shader skills and learn new techniques to achieve even more ambitious creations. While shaders on their own unlocked a new realm of what's possible to do on the web, there's one type of 3D object that I've overlooked until recently: particles!
Whether it's to create galaxies, stars, smoke, fire, or even some other abstract effects, particles are the best tool to help you create scenes that can feel truly magical 🪄.
However, particles can also feel quite intimidating at first. It takes a lot of practice to get familiar with the core concepts of particle-based scenes such as attributes or buffer geometries and advanced ones like combining them with custom shaders or using Frame Buffer Objects to push those scenes even further.
In this article, you will find all the tips and techniques I learned regarding particles, from creating simple particle systems with standard and buffer geometries to customizing how they look, controlling their movement with shaders, and techniques to scale the number of particles even further. You'll also get a deeper understanding of attributes, a key shader concept I overlooked in my previous blog post that is essential for these use cases.
An introduction to attributes
Before we can jump into creating gorgeous particle-based scenes with React Three Fiber, we have to talk about attributes.
What are attributes?
Attributes are pieces of data associated with each vertex of a mesh. If you've been playing with React Three Fiber and created some meshes, you've already used attributes without knowing! Each geometry associated with a mesh has a set of pre-defined attributes such as:
- The position attribute: an array of data representing all the positions of each vertex of a given geometry.
- The uv attribute: an array of data representing the UV coordinates of a given geometry.
These are just two examples among many possibilities, but you'll find these in pretty much any geometry you'll use. You can easily take a peek at them to see what kind of data it contains:
Logging the attributes of a geometry
1const Scene = () => {2const mesh = useRef();34useEffect(() => {5console.log(mesh.current.geometry.attributes);6}, []);78return <mesh ref={mesh}>{/* ... */}</mesh>;9};
You should see something like this:
If you're feeling confused right now, do not worry 😄. I was too! Seeing data like this can feel intimidating at first, but we'll make sense of all this just below.
Playing with attributes
This long array with lots of numbers represents the value of the x, y, and z coordinates for each vertex of our geometry. It's one-dimensional (no nested data), where each value x, y, and z of a given vertex is right next to the ones from the other vertex. I built the little widget below to illustrate in a more approachable way how the values of that position array translate to points in space:
Now that we know how to interpret that data, we can start having some fun with it. You can easily manipulate and modify attributes and create some nice effects without the need to touch shader code.
Below is an example where we use attributes to twist a boxGeometry
along its y-axis.
We do this effect by:
- Copying the original
position
attribute of the geometry.
1// Get the current attributes of the geometry2const currentPositions = mesh.current.geometry.attributes.position;3// Copy the attributes4const originalPositions = currentPositions.clone();
- Looping through each value of the array and applying a rotation.
1const originalPositionsArray = originalPositions?.array || [];23// Go through each vector (series of 3 values) and modify the values4for (let i = 0; i < originalPositionsArray.length; i = i + 3) {5// ...6}
- Pass the newly generated data to the geometry to replace the original
position
attribute array.
1// Apply the modified position vector coordinates to the current position attributes array2currentPositions.array[i] = modifiedPositionVector.x;3currentPositions.array[i + 1] = modifiedPositionVector.y;4currentPositions.array[i + 2] = modifiedPositionVector.z;
Attributes with Shaders
I briefly touched upon this subject when I introduced the notion of uniforms in The Study of Shaders with React Three Fiber but could not find a meaningful way to tackle it without making an already long article even longer.
We saw that we use uniforms to pass data from our Javascript code to a shader. Attributes are pretty similar in that regard as well, but there is one key difference:
- Data passed to a shader via a uniform remains constant between each vertex of a mesh (and pixels as well)
- Data passed via an attribute can be different for each vertex, allowing us to more fine-tuned controls of our vertex shader.
You can see that attributes allow us to control each vertex of a mesh, but not only! For particle-based scenes, we will heavily rely on them to:
- position our particles in space
- move, scale, or animate our particles through time
- customize each particle in a unique way
That is why it's necessary to have a somewhat clear understanding of attributes before getting started with particles.
Particles in React Three Fiber
Now that we know more about attributes, we can finally bring our focus to the core of this article: particles.
Our first scene with Particles
Remember how we can define a mesh as follows: mesh = geometry + material? Well, that definition also applies to points, the construct we use to create particles:
points = geometry + material
The only difference at this stage is that our points will use a specific type of material, the pointsMaterial.
Below you'll find an example of a particle system in React Three Fiber. As you can see, we're creating a system in the shape of a sphere by using
points
sphereGeometry
for our geometrypointsMaterial
for our material
Now you may ask me: this is great, but what if I want to position my particles more organically? What about creating a randomized cloud of particles? Well, this is where the notion of attributes comes into play!
Using BufferGeometry and attributes to create custom geometries
In Three.js and React Three Fiber, we can create custom geometries thanks to the use of:
bufferGeometry
bufferAttribute
- our newly acquired knowledge of attributes 🎉
When working with Particles, using a bufferGeometry
can be really powerful: it gives us full-control over the placement of each particle, and later we'll also see how this lets us animate them.
Let's take a look at how we can define a custom geometry in React Three Fiber with the following code example:
Custom geometry with bufferGeometry and bufferAttribute
1const CustomGeometryParticles = () => {2const particlesPosition = [3/* ... */4];56return (7<points ref={points}>8<bufferGeometry>9<bufferAttribute10attach="attributes-position"11count={particlesPosition.length / 3}12array={particlesPosition}13itemSize={3}14/>15</bufferGeometry>16<pointsMaterial17size={0.015}18color="#5786F5"19sizeAttenuation20depthWrite={false}21/>22</points>23);24};
In the code snippet above, we can see that:
- We are rendering a
bufferGeometry
as the geometry of our points. - In this
bufferGeometry
, we're using thebufferAttribute
element that lets us set the position attribute of our geometry.
Now let's take a look at the props
that we're passing to the bufferAttribute
element:
count
is the total number of vertex our geometry will have. In our case, it is the number of particles we will end up rendering.attach
is how we specify the name of our attribute. In this case, we set it asattributes-position
so the data we're feeding to thebufferAttribute
is available under theposition
attribute.itemSize
represents the number of values from our attributes array associated with one item/vertex. In this case, it's set to3
as we're dealing with theposition
attribute that has three components x, y, and z.
Now when it comes to creating the attributes array itself, let's look at the particlePositions
array located in our particle scene code.
Generating a position attribute array
1const count = 2000;23const particlesPosition = useMemo(() => {4// Create a Float32Array of count*3 length5// -> we are going to generate the x, y, and z values for 2000 particles6// -> thus we need 6000 items in this array7const positions = new Float32Array(count * 3);89for (let i = 0; i < count; i++) {10// Generate random values for x, y, and z on every loop11let x = (Math.random() - 0.5) * 2;12let y = (Math.random() - 0.5) * 2;13let z = (Math.random() - 0.5) * 2;1415// We add the 3 values to the attribute array for every loop16positions.set([x, y, z], i * 3);17}1819return positions;20}, [count]);
- First, we specify a
Float32Array
with a length ofcount * 3
. We're going to rendercount
particles, e.g. 2000, and each particle has three values (x, y, and z) associated with its position, i.e. *6000 values in total. - Then, we create a loop, and for each particle, we set all the values for x, y, and z. In this case, we're using some level of randomness to position our particles randomly.
- Finally, we're adding all three values to the array at the position
i * 3
withpositions.set([x,y,z], i*3)
.
The code sandbox below showcases what we can render with this technique of using custom geometries. In this example, I created two different position attribute arrays that place particles randomly:
- at the surface of a sphere
- in a box, which you can render by changing the
shape
prop tobox
and hitting reload.
We can see that using custom geometries lets us get a more organic render for our particle system, which looks prettier and opens up way more possibilities than standard geometries ✨.
Customizing and animating Particles with Shaders
Now that we know how to create a particle system based on custom geometries, we can start focusing on the fun part: animating particles! 🎉
There are two ways to approach animating particles:
- Using attributes (easier)
- Using shaders (a bit harder)
We'll look at both ways, although, as you may expect, if you know me a little bit through the work I share on Twitter, we're going to focus a lot on the second one. A little bit of challenge never hurts!
Animating Particles with attributes
For this part, we will see how to animate our particles by updating our position attribute array on every frame using the useFrame
hook. If you've animated meshes with React Three Fiber before, this method should be straightforward!
We just saw how to create an attributes array; updating it is pretty much the same process:
- We loop through the current values of the attributes array. It can be all the values or just some of them.
- Update them.
- And finally, the most important: set the
needsUpdate
field of our position attribute totrue
.
Animate particles via attributes in React Three Fiber
1useFrame((state) => {2const { clock } = state;34for (let i = 0; i < count; i++) {5const i3 = i * 3;67points.current.geometry.attributes.position.array[i3] +=8Math.sin(clock.elapsedTime + Math.random() * 10) * 0.01;9points.current.geometry.attributes.position.array[i3 + 1] +=10Math.cos(clock.elapsedTime + Math.random() * 10) * 0.01;11points.current.geometry.attributes.position.array[i3 + 2] +=12Math.sin(clock.elapsedTime + Math.random() * 10) * 0.01;13}1415points.current.geometry.attributes.position.needsUpdate = true;16});
The scene rendered below uses this technique to move the particles around their initial position, making the particle system feel a bit more alive ✨
Despite being the easiest, this method is also pretty expensive: on every frame, we have to loop through very long attribute arrays and update them. Over and over. As you might expect, this becomes a real problem as the number of particles grows. Thus it's preferable to delegate that part to the GPU with a sweet shader, which also has the added benefit to be more elegant. (a totally non-biased opinion from someone who dedicated weeks of their life working with shaders 😄).
How to animate our particles with a vertex shader
First and foremost, it's time to say goodbye to our pointsMaterial
👋, and replace it with a shaderMaterial
as follows:
How to use a custom shaderMaterial with particles and a custom buffer geometry
1const CustomGeometryParticles = (props) => {2const { count } = props;3const points = useRef();45const particlesPosition = useMemo(() => ({6// We set out positions here as we did before7)}, [])89const uniforms = useMemo(() => ({10uTime: {11value: 0.012},13// Add any other attributes here14}), [])1516useFrame((state) => {17const { clock } = state;1819points.current.material.uniforms.uTime.value = clock.elapsedTime;20});2122return (23<points ref={points}>24<bufferGeometry>25<bufferAttribute26attach="attributes-position"27count={particlesPosition.length / 3}28array={particlesPosition}29itemSize={3}30/>31</bufferGeometry>32<shaderMaterial33depthWrite={false}34fragmentShader={fragmentShader}35vertexShader={vertexShader}36uniforms={uniforms}37/>38</points>39);40}
As we learned in The Study of Shaders with React Three Fiber, we need to specify two functions for our shaderMaterial
:
- the fragment shader: this is where we'll focus on the next part to customize our particles
- the vertex shader: this is where we'll animate our particles
Vertex shader code that applies a rotation along the y-axis
1uniform float uTime;23void main() {4vec3 particlePosition = position * rotation3dY(uTime * 0.2);56vec4 modelPosition = modelMatrix * vec4(particlePosition, 1.0);7vec4 viewPosition = viewMatrix * modelPosition;8vec4 projectedPosition = projectionMatrix * viewPosition;910gl_Position = projectedPosition;11gl_PointSize = 3.0;12}
As you can see in the snippet above, when it comes to the code, animating particles using a shader is very similar to animating a mesh. With the vertex shader, you get to interact with the vertices of a geometry, which are the particles themselves in this use case.
Since we're there, let's iterate on that shader code to make the resulting scene even better: make the particles close to the center of the sphere move faster than the ones on the outskirts.
Enhanced version of the previous vertex shader
1uniform float uTime;2uniform float uRadius;34void main() {5float distanceFactor = pow(uRadius - distance(position, vec3(0.0)), 2.0);6vec3 particlePosition = position * rotation3dY(uTime * 0.2 * distanceFactor);78vec4 modelPosition = modelMatrix * vec4(particlePosition, 1.0);9vec4 viewPosition = viewMatrix * modelPosition;10vec4 projectedPosition = projectionMatrix * viewPosition;1112gl_Position = projectedPosition;13gl_PointSize = 3.0;14}
Which renders as the following once we wire this shader to our React Three Fiber code with a uTime
and uRadius
uniform:
How to change the size and appearance of our particles with shaders
This entire time, our particles were simple tiny squares, which is a bit boring. In this part, we'll look at how to fix this with some well-thought-out shader code.
First, let's look at the size. All our particles are the same size right now which does not really give off an organic vibe to this scene. To address that, we can tweak the gl_PointSize
property in our vertex shader code.
We can do multiple things with the point size:
- Making it a function of the position with some Perlin noise
- Making it a function of the distance from the center of your geometry
- Simply making it random
Anything is possible! For this example, we'll pick the second one:
Now, when it comes to the particle pattern itself, we can modify it in the fragment shader. I like to make my particles look like tiny points of light that we can luckily achieve with a few lines of code.
Fragment shader that changes the appearance of our particles
1varying float vDistance;23void main() {4vec3 color = vec3(0.34, 0.53, 0.96);5// Create a strength variable that's bigger the closer to the center of the particle the pixel is6float strength = distance(gl_PointCoord, vec2(0.5));7strength = 1.0 - strength;8// Make it decrease in strength *faster* the further from the center by using a power of 39strength = pow(strength, 3.0);1011// Ensure the color is only visible close to the center of the particle12color = mix(vec3(0.0), color, strength);13gl_FragColor = vec4(color, strength);14}
We can now make the colors of the particles a parameter of the material through a uniform and also make it a function of the distance to the center, for example:
Enhanced version of the previous fragment shader
1varying float vDistance;23void main() {4vec3 color = vec3(0.34, 0.53, 0.96);5float strength = distance(gl_PointCoord, vec2(0.5));6strength = 1.0 - strength;7strength = pow(strength, 3.0);89// Make particle close to the *center of the scene* a warmer color10// and the ones on the outskirts a cooler color11color = mix(color, vec3(0.97, 0.70, 0.45), vDistance * 0.5);12color = mix(vec3(0.0), color, strength);13// Here we're passing the strength in the alpha channel to make sure the outskirts14// of the particle are not visible15gl_FragColor = vec4(color, strength);16}
In the end, we get a beautiful set of custom particles with just a few lines of GLSL sprinkled on top of our particle system 🪄
Going beyond with Frame Buffer Objects
What if we wanted to render a lot more particles onto our scene? What about 100's of thousands? That would be pretty cool, right? With this advanced technique I'm about to show you, it is possible! And on top of that, with little to no frame drop 🔥!
This technique is named Frame Buffer Object (FBO). I stumbled upon it when I wanted to reproduce one of @winkerVSbecks attractor scenes from his blog post Three ways to create 3D particle effects.
Long story short, I wanted to build the same attractor effect but with shaders. The problem was that in an attractor, the position of a particle is dictated by its previous one, which doesn't work by just relying on the position attributes and a vertex shader: there's no way to get the updated position back to our Javascript code after it's been updated in our vertex shader and feed it back to the shader to calculate the next one! Thankfully, thanks to using an FBO, I figured out a way to render this scene.
How does a Frame Buffer Object work with particles?
I've seen many people using this technique in Three.js codebases. Here is how it goes: instead of initiating our particles positions array and passing it as an attribute and then render them, we are going to have 3 phases with two render passes.
- The simulation pass. We set the positions of the particles as a Data Texture to a shader material. They are then read, returned, and sometimes modified in the material's fragment shader (you heard me right!).
- Create a
WebGLRenderTarget
, a "texture" we can render to off-screen where we will add a small scene containing our material from the simulation pass and a small plane. We then set it as the current render target, thus rendering our simulation material with its Data Texture that is filled with position data. - The render pass. We can now read the texture rendered in the render target. The texture data is the positions array of our particles, which we can now pass as a
uniform
to our particles'shaderMaterial
.
In the end, we're using the simulation pass as a buffer to store data and do a lot of heavy calculations on the GPU by processing our positions in a fragment shader, and we do that on every single frame. Hence the name Frame Buffer Object. I hope I did not lose you there 😅. Maybe the diagram below, as well as the following code snippet will help 👇:
Setting up a simulation material
1import { extend } from '@react-three/fiber';2// ... other imports34const generatePositions = (width, height) => {5// we need to create a vec4 since we're passing the positions to the fragment shader6// data textures need to have 4 components, R, G, B, and A7const length = width * height * 4;8const data = new Float32Array(length);910// Fill Float32Array here1112return data;13};1415// Create a custom simulation shader material16class SimulationMaterial extends THREE.ShaderMaterial {17constructor(size) {18// Create a Data Texture with our positions data19const positionsTexture = new THREE.DataTexture(20generatePositions(size, size),21size,22size,23THREE.RGBAFormat,24THREE.FloatType25);26positionsTexture.needsUpdate = true;2728const simulationUniforms = {29// Pass the positions Data Texture as a uniform30positions: { value: positionsTexture },31};3233super({34uniforms: simulationUniforms,35vertexShader: simulationVertexShader,36fragmentShader: simulationFragmentShader,37});38}39}4041// Make the simulation material available as a JSX element in our canva42extend({ SimulationMaterial: SimulationMaterial });
Setting up an FBO with a simulation material in React Three Fiber
1import { useFBO } from '@react-three/drei';2import { useFrame, createPortal } from '@react-three/fiber';34const FBOParticles = () => {5const size = 128;67// This reference gives us direct access to our points8const points = useRef();9const simulationMaterialRef = useRef();1011// Create a camera and a scene for our FBO12const scene = new THREE.Scene();13const camera = new THREE.OrthographicCamera(14-1,151,161,17-1,181 / Math.pow(2, 53),19120);2122// Create a simple square geometry with custom uv and positions attributes23const positions = new Float32Array([24-1,25-1,260,271,28-1,290,301,311,320,33-1,34-1,350,361,371,380,39-1,401,410,42]);43const uvs = new Float32Array([0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0]);4445// Create our FBO render target46const renderTarget = useFBO(size, size, {47minFilter: THREE.NearestFilter,48magFilter: THREE.NearestFilter,49format: THREE.RGBAFormat,50stencilBuffer: false,51type: THREE.FloatType,52});5354// Generate a "buffer" of vertex of size "size" with normalized coordinates55const particlesPosition = useMemo(() => {56const length = size * size;57const particles = new Float32Array(length * 3);58for (let i = 0; i < length; i++) {59let i3 = i * 3;60particles[i3 + 0] = (i % size) / size;61particles[i3 + 1] = i / size / size;62}63return particles;64}, [size]);6566const uniforms = useMemo(67() => ({68uPositions: {69value: null,70},71}),72[]73);7475useFrame((state) => {76const { gl, clock } = state;7778// Set the current render target to our FBO79gl.setRenderTarget(renderTarget);80gl.clear();81// Render the simulation material with square geometry in the render target82gl.render(scene, camera);83// Revert to the default render target84gl.setRenderTarget(null);8586// Read the position data from the texture field of the render target87// and send that data to the final shaderMaterial via the `uPositions` uniform88points.current.material.uniforms.uPositions.value = renderTarget.texture;8990simulationMaterialRef.current.uniforms.uTime.value = clock.elapsedTime;91});9293return (94<>95{/* Render off-screen our simulation material and square geometry */}96{createPortal(97<mesh>98<simulationMaterial ref={simulationMaterialRef} args={[size]} />99<bufferGeometry>100<bufferAttribute101attach="attributes-position"102count={positions.length / 3}103array={positions}104itemSize={3}105/>106<bufferAttribute107attach="attributes-uv"108count={uvs.length / 2}109array={uvs}110itemSize={2}111/>112</bufferGeometry>113</mesh>,114scene115)}116<points ref={points}>117<bufferGeometry>118<bufferAttribute119attach="attributes-position"120count={particlesPosition.length / 3}121array={particlesPosition}122itemSize={3}123/>124</bufferGeometry>125<shaderMaterial126blending={THREE.AdditiveBlending}127depthWrite={false}128fragmentShader={fragmentShader}129vertexShader={vertexShader}130uniforms={uniforms}131/>132</points>133</>134);135};
Creating magical scenes with FBO
To demonstrate the power of FBO, let's look at two scenes I built with this technique 👀.
The first one renders a particle system in the shape of a sphere with randomly positioned points. In the simulationMaterial
, I applied a curl-noise to the position data of the particles, which yields the gorgeous effect you can see below ✨!
In this scene, we:
- render
128 x 128
(the resolution of our render target) particles. - apply a curl noise to each of our particles in our simulation pass.
- pass all that data along to the
renderMaterial
that takes care to render each vertex with that position data and also the particle size using thegl_pointSize
property.
Finally, one last scene, just for fun! I ported to React Three Fiber a Three.js demo from an article written by @nicoptere that does a pretty good job at deep diving into the FBO technique.
In it, I pass not only one but two Data Textures:
- the first one contains the data to position the particles as a box
- the second one as a sphere
Then in the fragment shader of the simulationMaterial
, we use GLSL's mix
function to alternate over time between the two "textures" which results in this scene where the particles morph from one shape to another.
Conclusion
From zero to FBO, you now know pretty much everything I know about particles as of writing these words 🎉! There's, of course, still a lot more to explore, but I hope this blog post was a good introduction to the basics and more advanced techniques and that it can serve as a guide to get back to during your own journey with Particles and React Three Fiber.
Techniques like FBO enable almost limitless possibilities for particle-based scenes, and I can't wait to see what you'll get to create with it ✨. I couldn't resist sharing this with you in this write-up 🪄. Frame Buffer Objects have a various set of use cases, not just limited to particles that I haven't explored deeply enough yet. That will probably be a topic for a future blog post, who knows?
As a productive next step to push your particle skills even further, I can only recommend to hack on your own. You now have all the tools to get started 😄.
Liked this article? Share it with a friend on Bluesky or Twitter or support me to take on more ambitious projects to write about. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll do my best to get back to you.
Have a wonderful day.
– Maxime
An interactive introduction to Particles with React Three Fiber and Shaders where you'll learn anything going from attributes, buffer geometries, and more advanced techniques like Frame Buffer Object through 8 unique 3D scenes.