webdevpuneet.com

Pixi.js Particle Field — WebGL Sprite Background

Pixi.js Particle Field · Animations · Plain HTML, CSS & JS · Live preview

CategoryAnimations

Copy & paste

Source Code

Requires
<div class="ppf-stage" id="ppfStage">
  <div class="ppf-ui">
    <span class="ppf-tag">pixi.js · webgl</span>
    <h2>2,400 sprites, one draw call</h2>
    <p>Move your pointer through the field — every particle is repelled individually.</p>
    <div class="ppf-row">
      <span class="ppf-fps"><b id="ppfFps">60</b> fps</span>
      <button class="ppf-btn" id="ppfBurst">Burst</button>
    </div>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Single batched draw call
Shared texture and blend mode let 2,400 sprites batch together.
Generated GPU texture
One Graphics circle rendered to texture, then destroyed.
Tint instead of textures
A white source tinted per sprite keeps five colors in one batch.
ParticleContainer whitelist
Only position, scale, tint and alpha are uploaded per frame.
Squared-distance culling
Math.sqrt runs only for particles actually in range.
Additive glow
Overlapping sprites sum to bright clusters with no bloom filter.
Delta-scaled motion
Ticker delta keeps speed identical at 30fps and 144Hz.
Capped retina resolution
devicePixelRatio clamped to 2 so phones do not shade 9x pixels.

About this UI Snippet

Pixi.js Particle Field — Why WebGL Beats Canvas 2D at Scale

Screenshot of the Pixi.js Particle Field snippet rendered live

A canvas 2D particle background is fine at a few hundred particles. Past roughly a thousand it starts costing frames, because every particle is a separate arc() and fill() and the CPU is issuing every one of those calls individually.

Pixi.js is a WebGL renderer, and it changes the economics completely: sprites that share a texture and blend mode are batched into a single draw call and rasterized by the GPU in parallel. This field runs 2,400 particles with pointer physics comfortably at 60fps, and the interesting part is the handful of decisions that make that possible.

One texture, generated once

Every particle is the same white circle:

var g = new PIXI.Graphics(); g.beginFill(0xffffff); g.drawCircle(0, 0, 24); g.endFill(); var texture = app.renderer.generateTexture(g, { resolution: 2 }); g.destroy();

The circle is drawn once into a GPU texture, and all 2,400 sprites reference that same texture. The Graphics object is destroyed immediately afterward — it was scaffolding, and keeping it alive would leak its geometry.

Crucially the circle is white, because color comes from sprite.tint. Tinting is a per-sprite multiply applied in the shader, so five different colors do not mean five textures and five draw calls. If each color were its own texture, batching would break into five batches — the exact mistake that makes people conclude WebGL "isn't faster."

Drawing the source circle at radius 24 and then scaling sprites down to 0.06–0.22 is deliberate: scaling a texture *down* stays sharp, scaling *up* goes soft.

ParticleContainer, and what it gives up

new PIXI.ParticleContainer(COUNT, { position: true, scale: true, tint: true, alpha: true })

ParticleContainer is a stripped-down Container built for exactly this case. Its children cannot have their own children, filters, or masks, and it only uploads the properties you explicitly enable. That is why the options object is a whitelist — declaring rotation when nothing rotates means uploading 2,400 unused floats to the GPU every frame.

That is the trade: fewer features, dramatically less per-frame work.

The optimization that matters most

Inside the ticker, the pointer repulsion uses squared distances:

var d2 = dx * dx + dy * dy; if (d2 < RADIUS_SQ && d2 > 0.01) { var d = Math.sqrt(d2); ... }

Math.sqrt is comparatively expensive and, more importantly, completely unnecessary for a *comparison*. If d² < r² then d < r — so the square root is only computed for particles actually within the radius, which is usually a small fraction of the field. At 2,400 particles across 60 frames that avoids roughly 140,000 square roots per second. This is the single most transferable idea in the file and it applies to any distance check in any language.

Additive blending

sp.blendMode = PIXI.BLEND_MODES.ADD makes overlapping particles sum their color values rather than paint over each other. Dense regions blow out toward white and sparse ones stay dim, which produces the glowing, energetic look without any bloom filter. It also means low per-sprite alpha (0.25–0.75) is intentional: with additive blending, brightness comes from *overlap*, so starting particles dim leaves headroom for clusters to glow.

Velocity, damping, and delta

Each particle carries its own velocity, gets pushed away from the pointer proportionally to how close it is ((1 - d / RADIUS), so the force falls off to zero at the edge rather than cutting off abruptly), then has that velocity damped by 0.96 each frame. Damping is what makes the field settle instead of accumulating energy forever.

app.ticker.add(function (delta) { ... }) supplies a delta multiplier normalized so 1.0 means 60fps. Multiplying movement by it keeps the field moving at the same real-world speed on a 30fps laptop and a 144Hz monitor.

Retina without the cost blowup

resolution: Math.min(window.devicePixelRatio, 2) with autoDensity: true renders crisply on high-DPI screens while capping the pixel count. A phone reporting DPR 3 would otherwise ask the GPU to shade nine times as many pixels as DPR 1 for a difference nobody can see. resizeTo: stage keeps the renderer matched to its container with no resize handler to write.

Reusing it

COUNT is the main dial — 2,400 is comfortable on a desktop; halve it on mobile. Change TINTS for your brand, and keep the source texture white so tinting continues to batch. For a connection-based look instead of a free field, compare particle network; for a noise-steered variant, p5.js flow field uses the same particle count on canvas 2D and shows exactly where that approach runs out.

Step by step

How to Use

  1. 1
    Add the Pixi.js CDNInclude pixi.js v7 from the CDN panel — global PIXI.
  2. 2
    Paste HTML, CSS, and JSA full-viewport WebGL particle field starts immediately.
  3. 3
    Move your pointerParticles within 150px are pushed away with falloff.
  4. 4
    Watch the FPS readoutLive from app.ticker.FPS, sampled twice a second.
  5. 5
    Press BurstEvery particle is re-seeded at center with radial velocity.
  6. 6
    Tune the countAdjust COUNT and TINTS — keep the source texture white.

Real-world uses

Common Use Cases

High-density hero backgrounds
Render 2,400 additive sprites in one batched draw call, far beyond what canvas 2D handles smoothly with separate fills.
Audio-reactive visuals
Drive the repulsion radius from audio or event intensity, with only position, scale, tint and alpha uploaded per frame.
WebGL landing page openers
Make a strong first impression with a GPU field, using one generated circle texture tinted per sprite to give five colours.
Interactive installations
Let pointer or camera input steer the particles, where a ParticleContainer whitelist keeps per-frame uploads minimal.
Batching comparisons
Learn why batching matters by comparing against the canvas-based particle network at the same particle counts.

Got questions?

Frequently Asked Questions

Canvas 2D issues a separate arc and fill call per particle from the CPU. Pixi batches every sprite that shares a texture and blend mode into a single WebGL draw call, and the GPU rasterizes them in parallel. That is why 2,400 particles run comfortably here where the same count in canvas 2D would start costing frames.

Because color comes from sprite.tint, which is a per-sprite multiply applied in the shader. A white texture tinted five ways stays one texture and therefore one batch. Creating five colored textures instead would split rendering into five draw calls and lose most of the benefit — a common mistake that leads people to conclude WebGL is not faster.

Its children cannot have their own children, filters, or masks. In exchange it only uploads the properties you explicitly enable in its options object, so declaring rotation when nothing rotates would mean pushing 2,400 unused floats to the GPU every frame. It is a deliberate feature-for-throughput trade.

A square root is expensive and unnecessary for a comparison: if d squared is less than r squared, then d is less than r. Computing sqrt only for particles actually inside the radius avoids roughly 140,000 square roots per second at 2,400 particles and 60fps. The technique applies to any distance check anywhere.

Because additive blending means brightness comes from overlap. Starting each sprite at 0.25 to 0.75 alpha leaves headroom so dense clusters sum toward white and glow while sparse regions stay dim. Fully opaque particles would saturate immediately and the field would lose all its depth.

Create the Application in a mount effect against a container ref and append app.view there. In cleanup call app.destroy(true, { children: true, texture: true }) — otherwise every remount leaks a WebGL context, and browsers cap how many can exist, so after a dozen navigations the canvas simply goes blank. Keep the particle array in a ref, never state, since it mutates every frame.