Skip to content
Three.js Particles & Particle Systems — Complete Guide to Points, Sprites, Physics & Effects

Three.js Particles & Particle Systems — Complete Guide to Points, Sprites, Physics & Effects

DodaTech Updated Jun 6, 2026 13 min read

Three.js particle systems create thousands of tiny objects that move, fade, and change color to simulate natural phenomena — rain, snow, fire, smoke, stars, galaxies, and magic effects. They’re what turns a static scene into a living world.

What You’ll Learn

By the end of this tutorial, you’ll build custom particle systems using THREE.Points, control individual particle behavior with BufferGeometry attributes, create canvas-generated textures, implement physics (gravity, wind, attractors), build rain/snow/fire effects, and generate an interactive spiral galaxy with real-time parameter controls.

Why Particles Matter

Particles create atmosphere. A few thousand floating specks turn an empty scene into a starry night. A rain effect makes a cityscape feel alive. In Durga Antivirus Pro, the 3D threat dashboard uses particle systems to show network traffic: benign traffic flows as slow green particles, suspicious activity as fast orange streaks, and detected threats as red bursts that expand and fade — giving analysts an instant visual read of network health.

    flowchart LR
    A[Points + PointsMaterial] --> B[BufferGeometry Attributes]
    B --> C[Position: XYZ per particle]
    B --> D[Color: RGB per particle]
    B --> E[Size: Custom per particle]
    C --> F[Canvas Textures]
    D --> F
    F --> G[Particle Physics]
    G --> H[Gravity / Wind / Attractors]
    H --> I[Effects]
    I --> J[Rain]
    I --> K[Snow]
    I --> L[Fire]
    I --> M[Galaxy Generator]
    M --> N[Performance Optimization]
    style A fill:#44aa88,color:#fff,stroke:none
  
Prerequisites: https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-basics/ and https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-geometries-materials/. You need geometry and material knowledge — particles use a special material type (PointsMaterial) with BufferGeometry.

Points & PointsMaterial — The Particle Foundation

THREE.Points renders a set of vertices as particles. Unlike Mesh, which forms solid surfaces, Points draws each vertex as an independent dot.

// 1. Create a BufferGeometry to hold particle positions
var geometry = new THREE.BufferGeometry();
var count = 1000;
var positions = new Float32Array(count * 3);  // 3 floats per vertex (X, Y, Z)

// 2. Fill with random positions within a 100-unit cube
for (var i = 0; i < count * 3; i++) {
    positions[i] = (Math.random() - 0.5) * 100;
}

// 3. Set the position attribute (3 components per vertex)
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));

// 4. Create a PointsMaterial (unlit — particles don't need lights)
var material = new THREE.PointsMaterial({
    color: 0xffffff,
    size: 0.5,
    sizeAttenuation: true  // Particles shrink with distance (perspective)
});

// 5. Create the Points object and add to scene
var particles = new THREE.Points(geometry, material);
scene.add(particles);

Why Points instead of Sprites? THREE.Points renders all particles in a single draw call — the GPU draws them all at once. THREE.Sprite creates a separate Object3D per particle — each one adds CPU overhead. Use Points for thousands of particles. Use Sprites only when each particle needs independent rotation or scale.

PointsMaterial Properties

PropertyTypeDefaultDescription
colorColor0xffffffBase tint color
sizenumber1Particle size (pixels or world units based on attenuation)
sizeAttenuationbooleantrueShrink with distance (perspective)
mapTexturenullCustom texture (round gradient for soft particles)
transparentbooleanfalseEnable transparency
opacitynumber1Overall opacity
blendingBlendingNormalBlendingAdditiveBlending for glow effects
depthWritebooleantrueSet false for transparent particles
vertexColorsbooleanfalseUse per-vertex colors from geometry attributes

BufferGeometry Attributes for Particles

Each particle can have custom position, color, and size:

Position — Where Each Particle Lives

var count = 5000;
var positions = new Float32Array(count * 3);

for (var i = 0; i < count; i++) {
    var i3 = i * 3;
    var radius = 20;
    var theta = Math.random() * Math.PI * 2;
    var phi = Math.acos(2 * Math.random() - 1);

    positions[i3]     = radius * Math.sin(phi) * Math.cos(theta);
    positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
    positions[i3 + 2] = radius * Math.cos(phi);
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));

Color — Per-Particle Colors

var colors = new Float32Array(count * 3);
for (var i = 0; i < count; i++) {
    var i3 = i * 3;
    var color = new THREE.Color();
    color.setHSL(Math.random(), 0.8, 0.5 + Math.random() * 0.5);
    colors[i3] = color.r;
    colors[i3 + 1] = color.g;
    colors[i3 + 2] = color.b;
}
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));

// Enable vertexColors on the material
var material = new THREE.PointsMaterial({
    vertexColors: true,       // Use per-vertex colors
    size: 0.3,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false
});

Custom Canvas Texture — No External Images

A canvas-generated texture avoids file loading and works offline:

function createParticleTexture() {
    var canvas = document.createElement("canvas");
    canvas.width = 64;
    canvas.height = 64;
    var ctx = canvas.getContext("2d");

    // Radial gradient: bright center → transparent edge
    var gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
    gradient.addColorStop(0, "rgba(255,255,255,1)");
    gradient.addColorStop(0.3, "rgba(255,255,255,0.8)");
    gradient.addColorStop(1, "rgba(255,255,255,0)");

    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, 64, 64);
    return new THREE.CanvasTexture(canvas);
}

var texture = createParticleTexture();
var material = new THREE.PointsMaterial({
    size: 0.5,
    map: texture,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false
});

Why canvas textures? Loading textures from files causes a delay (network request) and can fail. A canvas texture is instant, always available, and customizable. The radial gradient creates soft, glowing particles that look much better than hard squares.

Animated Particles — Updating Positions Each Frame

var positions = geometry.attributes.position.array;  // Reference to existing buffer
var count = positions.length / 3;

function animateParticles() {
    var time = Date.now() * 0.001;

    for (var i = 0; i < count; i++) {
        var i3 = i * 3;
        var angle = time + i * 0.01;
        var radius = 10 + Math.sin(time + i * 0.1) * 5;

        positions[i3]     = Math.cos(angle) * radius;
        positions[i3 + 1] = Math.sin(angle * 0.5) * 3;
        positions[i3 + 2] = Math.sin(angle) * radius;
    }

    geometry.attributes.position.needsUpdate = true;  // ⚠️ Tell Three.js to re-upload
}

Why needsUpdate = true? Three.js uploads geometry data to the GPU once. If you modify the position array but don’t set needsUpdate, the GPU still uses the old data. Setting it to true triggers a re-upload.

Particle Physics

Gravity

var velocities = [];
for (var i = 0; i < count; i++) {
    velocities.push({
        x: (Math.random() - 0.5) * 0.1,
        y: (Math.random() - 0.5) * 0.1,
        z: (Math.random() - 0.5) * 0.1
    });
}

function applyGravity(positions, velocities, gravity, delta) {
    for (var i = 0; i < count; i++) {
        var i3 = i * 3;
        velocities[i].y -= gravity * delta;  // Pull particles down
        positions[i3]     += velocities[i].x * delta;
        positions[i3 + 1] += velocities[i].y * delta;
        positions[i3 + 2] += velocities[i].z * delta;
    }
    geometry.attributes.position.needsUpdate = true;
}

Particle Recycling (Respawn)

When particles fall below a threshold, reset them to the top:

function recycleParticles(positions, velocities, height) {
    for (var i = 0; i < count; i++) {
        var i3 = i * 3;
        if (positions[i3 + 1] < height) {
            positions[i3]     = (Math.random() - 0.5) * 20;
            positions[i3 + 1] = 10 + Math.random() * 5;
            positions[i3 + 2] = (Math.random() - 0.5) * 20;
            velocities[i].x = (Math.random() - 0.5) * 0.05;
            velocities[i].y = 0;
            velocities[i].z = (Math.random() - 0.5) * 0.05;
        }
    }
}

Galaxy Generator — Spiral Particles

A spiral galaxy creates thousands of particles arranged in arms:

function createGalaxy(parameters) {
    var count = parameters.count || 20000;
    var arms = parameters.arms || 3;
    var radius = parameters.radius || 20;
    var spin = parameters.spin || 1.5;
    var randomness = parameters.randomness || 0.4;
    var insideColor = new THREE.Color(parameters.insideColor || 0xffaa00);
    var outsideColor = new THREE.Color(parameters.outsideColor || 0x4488ff);

    var positions = new Float32Array(count * 3);
    var colors = new Float32Array(count * 3);

    for (var i = 0; i < count; i++) {
        var i3 = i * 3;
        var r = Math.pow(Math.random(), 1.5) * radius;
        var armAngle = (r / radius) * spin * Math.PI * 2;
        var armOffset = (i % arms) / arms * Math.PI * 2;
        var spread = Math.pow(Math.random(), 3) * randomness;

        positions[i3] = Math.cos(armAngle + armOffset) * r + Math.cos(spread * Math.PI * 2) * spread;
        positions[i3 + 1] = (Math.random() - 0.5) * 0.4;
        positions[i3 + 2] = Math.sin(armAngle + armOffset) * r + Math.sin(spread * Math.PI * 2) * spread;

        var mixFactor = r / radius;
        var mixed = insideColor.clone().lerp(outsideColor, mixFactor);
        colors[i3] = mixed.r;
        colors[i3 + 1] = mixed.g;
        colors[i3 + 2] = mixed.b;
    }

    var geometry = new THREE.BufferGeometry();
    geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));

    var material = new THREE.PointsMaterial({
        size: 0.08, vertexColors: true, transparent: true,
        blending: THREE.AdditiveBlending, depthWrite: false,
        sizeAttenuation: true, map: createGlowTexture()
    });

    return new THREE.Points(geometry, material);
}

Common Mistakes

1. Allocating a new Float32Array each frame

Creating new Float32Array() every frame causes garbage collection stutters. Mutate the existing array and set needsUpdate = true.

2. Using Sprites for thousands of particles

Each Sprite is an Object3D with CPU overhead. For large particle counts, use Points (single draw call).

3. Forgetting depthWrite: false with AdditiveBlending

Additive particles with depthWrite: true render incorrectly — they block each other instead of blending.

4. Not using sizeAttenuation

Particles stay the same size regardless of distance, breaking the sense of depth and perspective.

5. Modifying attributes without needsUpdate

Changes to the typed array have no visual effect until geometry.attributes.position.needsUpdate = true.

6. Same random seed producing clusters

Without proper randomization, particles clump together. Ensure your random distribution covers the desired space evenly.

7. Mixing additive and normal blending in the same Points object

All particles in one Points object must share the same material/blending. Use separate systems for different blend modes.

Practice Questions

Q1: What’s the difference between THREE.Points and THREE.Sprite for particles? A: Points renders all particles in one draw call (GPU-efficient). Sprites are individual Object3Ds (CPU-heavy). Use Points for thousands, Sprites only when each needs independent rotation/scale.

Q2: Why do transparent particles need depthWrite: false? A: With depthWrite enabled, transparent particles write to the depth buffer and block other particles behind them. Disabling it allows proper additive/normal blending.

Q3: What does needsUpdate = true do? A: It tells Three.js to re-upload the BufferAttribute to the GPU. Without it, changes to the typed array are invisible.

Q4: How do you create a soft, round particle texture without loading an image? A: Use a canvas with a radial gradient (bright center → transparent edge), then create a CanvasTexture from it. No file loading needed.

Q5: How many particles can Three.js handle? A: With Points, 50,000-100,000 runs well on modern devices. Beyond 200,000, use custom ShaderMaterial or GPU compute.

Challenge: Build a firework effect: particles shoot upward from a point, then explode outward in random directions with gravity pulling them down. Use color gradient (white → yellow → orange → dark) based on particle age. Particle count: 500 per burst.

FAQ

Why use Points instead of Sprite for particles?
Points renders all particles in a single draw call using BufferGeometry. Sprites are individual Object3D instances — each adds overhead. Use Points for thousands, Sprites for <500 when per-object manipulation is needed.
How do I add a texture to PointsMaterial?
Set the map property to a Texture. A canvas-generated radial gradient texture creates soft, glowing particles without external file loading.
What is sizeAttenuation?
When true, particles shrink with distance (perspective). When false, all particles render the same pixel size regardless of distance.
How do I update particle positions each frame?
Modify the existing Float32Array from geometry.attributes.position.array in place, then set needsUpdate = true. Don’t create a new array.
What is AdditiveBlending?
AdditiveBlending sums RGB values of overlapping particles, creating a glow/light effect. Colors get brighter where particles overlap. Use for fire, stars, magic effects.
How many particles can Three.js handle?
50,000-100,000 with Points on modern devices. Beyond 200,000, consider custom ShaderMaterial or GPU compute.
Can I use different colors per particle?
Yes. Add a ‘color’ attribute to BufferGeometry and set vertexColors: true on PointsMaterial.

Try It Yourself

Copy this complete HTML file. It’s an interactive Galaxy Generator with real-time sliders for arms, spin, speed, particle count, and colors.

<!DOCTYPE html>
<html>
<head>
    <title>Galaxy Generator — Three.js Particle System</title>
    <style>
        body { margin: 0; overflow: hidden; font-family: sans-serif; }
        #controls {
            position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
            background: rgba(0,0,0,0.7); padding: 16px 24px; border-radius: 8px;
            color: white; display: flex; gap: 20px; flex-wrap: wrap;
            justify-content: center; backdrop-filter: blur(8px);
            font-size: 13px; z-index: 10;
        }
        #controls label { display: flex; flex-direction: column; align-items: center; gap: 2px; }
        #controls input[type="range"] { width: 100px; cursor: pointer; }
        #controls input[type="color"] { width: 40px; height: 30px; border: none; cursor: pointer; }
        #controls span { font-size: 11px; opacity: 0.8; }
    </style>
</head>
<body>
    <div id="controls">
        <label>Arms <input type="range" id="arms" min="1" max="8" value="4" step="1"><span id="armsVal">4</span></label>
        <label>Spin <input type="range" id="spin" min="0.2" max="3" value="1.2" step="0.1"><span id="spinVal">1.2</span></label>
        <label>Speed <input type="range" id="speed" min="0" max="0.01" value="0.003" step="0.001"><span id="speedVal">0.003</span></label>
        <label>Size <input type="range" id="size" min="0.02" max="0.5" value="0.08" step="0.01"><span id="sizeVal">0.08</span></label>
        <label>Particles (K) <input type="range" id="count" min="5" max="50" value="20" step="1"><span id="countVal">20</span></label>
        <label>Inside <input type="color" id="insideColor" value="#ffaa00"></label>
        <label>Outside <input type="color" id="outsideColor" value="#4488ff"></label>
    </div>

    <script type="importmap">
    { "imports": {
        "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
        "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
    }}
    </script>
    <script type="module">
        import * as THREE from "three";
        import { OrbitControls } from "three/addons/controls/OrbitControls.js";

        var scene = new THREE.Scene();
        scene.background = new THREE.Color(0x050510);
        var camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
        camera.position.set(15, 8, 20);
        var renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        document.body.appendChild(renderer.domElement);
        new OrbitControls(camera, renderer.domElement);

        function createGlowTexture() {
            var canvas = document.createElement("canvas");
            canvas.width = 128; canvas.height = 128;
            var ctx = canvas.getContext("2d");
            var g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
            g.addColorStop(0, "rgba(255,255,255,1)");
            g.addColorStop(0.15, "rgba(255,255,255,1)");
            g.addColorStop(0.5, "rgba(255,255,255,0.3)");
            g.addColorStop(1, "rgba(255,255,255,0)");
            ctx.fillStyle = g;
            ctx.fillRect(0, 0, 128, 128);
            return new THREE.CanvasTexture(canvas);
        }

        var galaxy, parameters = { count: 20000, arms: 4, radius: 20, spin: 1.2, randomness: 0.4, randomnessPower: 3, insideColor: "#ffaa00", outsideColor: "#4488ff", size: 0.08, speed: 0.003 };
        var clock = new THREE.Clock();

        function generateGalaxy() {
            if (galaxy) { scene.remove(galaxy); galaxy.geometry.dispose(); galaxy.material.dispose(); }
            var count = parameters.count;
            var positions = new Float32Array(count * 3);
            var colors = new Float32Array(count * 3);
            var colorInside = new THREE.Color(parameters.insideColor);
            var colorOutside = new THREE.Color(parameters.outsideColor);

            for (var i = 0; i < count; i++) {
                var i3 = i * 3;
                var r = Math.pow(Math.random(), 1.5) * parameters.radius;
                var armAngle = (r / parameters.radius) * parameters.spin * Math.PI * 2;
                var armOffset = (i % parameters.arms) / parameters.arms * Math.PI * 2;
                var spread = Math.pow(Math.random(), parameters.randomnessPower) * parameters.randomness;
                positions[i3] = Math.cos(armAngle + armOffset) * r + Math.cos(spread * Math.PI * 2) * spread * r * 0.3;
                positions[i3 + 1] = (Math.random() - 0.5) * 0.4;
                positions[i3 + 2] = Math.sin(armAngle + armOffset) * r + Math.sin(spread * Math.PI * 2) * spread * r * 0.3;
                var mix = r / parameters.radius;
                var c = colorInside.clone().lerp(colorOutside, mix);
                colors[i3] = c.r; colors[i3 + 1] = c.g; colors[i3 + 2] = c.b;
            }
            var geometry = new THREE.BufferGeometry();
            geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
            geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
            var material = new THREE.PointsMaterial({ size: parameters.size, vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true, map: createGlowTexture() });
            galaxy = new THREE.Points(geometry, material);
            scene.add(galaxy);
        }

        generateGalaxy();

        ["arms","spin","size","count"].forEach(function(id) {
            document.getElementById(id).addEventListener("input", function() {
                var v = id === "arms" ? parseInt(this.value) : id === "count" ? parseInt(this.value) * 1000 : parseFloat(this.value);
                parameters[id] = v;
                document.getElementById(id + "Val").textContent = this.value;
                generateGalaxy();
            });
        });
        document.getElementById("speed").addEventListener("input", function() {
            parameters.speed = parseFloat(this.value);
            document.getElementById("speedVal").textContent = this.value;
        });
        ["insideColor","outsideColor"].forEach(function(id) {
            document.getElementById(id).addEventListener("input", function() {
                parameters[id] = this.value;
                generateGalaxy();
            });
        });

        function animate() {
            requestAnimationFrame(animate);
            if (galaxy) { galaxy.rotation.y += parameters.speed * 0.3; }
            renderer.render(scene, camera);
        }
        animate();

        window.addEventListener("resize", function() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });
    </script>
</body>
</html>

What’s Next

TopicDescriptionLink
Post-ProcessingBloom and effects that enhance particleshttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-post-processing/
AnimationAdvanced animation for particle motionhttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-animation/
Models & LoadingAdding particles to loaded 3D sceneshttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-models-loading/
Interactivity & UIInteractive particle systemshttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-interactivity-ui/
Geometries & MaterialsUnderstanding BufferGeometryhttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-geometries-materials/
WebGLThe underlying graphics APIWebGL
JavaScriptLanguage fundamentalsJavaScript

You’ve mastered Three.js particles — from Points and BufferGeometry attributes to physics simulations and procedural galaxy generation. The same particle techniques power the traffic flow visualization in Durga Antivirus Pro’s threat dashboard, where green (safe) and red (threat) particles create an intuitive real-time network health display.

What’s Next

Congratulations on completing this Threejs Particles tutorial! Here’s where to go from here:

  • Practice daily — Consistency is more important than long study sessions
  • Build a project — Apply what you learned by building something real
  • Explore related topics — Check out other tutorials in the same category
  • Join the community — Discuss with other learners and share your progress

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro