Performance Optimisation
Push Processing to 60fps with 10,000 particles by mastering renderer selection, PShape batching, spatial hashing, and background threading.
Processing sketches are Java programs. Everything that applies to Java performance — object allocation pressure, cache coherence, branch prediction — applies here. This article works through a 10,000-particle simulation and progressively optimises it to 60fps.
Renderer Selection
The renderer is the single biggest performance lever. Choose it at size() and never change it at runtime.
- Java2D (default): software-rendered. Fast for 2D shape drawing with stroke, terrible for heavy per-pixel work.
- P2D: OpenGL-accelerated 2D. Hardware-blended primitives, shader support, best for particle systems.
- P3D: Full OpenGL 3D pipeline. Use even for “flat” 3D work when you need
PShapebatching. - FX2D: JavaFX renderer. Higher quality anti-aliasing than Java2D, but no shader support.
For particle systems, P2D is almost always the right choice:
void setup() {
size(1280, 720, P2D);
smooth(4); // MSAA level — reduce to 2 or 0 if GPU-bound
}
Measure before optimising. Print actual frame rate in draw():
surface.setTitle(nf(frameRate, 0, 1) + " fps");
PShape for Geometry Caching
PShape is a retained-mode scene object that uploads geometry to the GPU once and redraws it cheaply. Drawing 1,000 individual ellipse() calls re-issues 1,000 draw commands per frame; a single PShape with 1,000 vertices issues one.
PShape cloud;
void setup() {
size(1280, 720, P2D);
cloud = createShape(GROUP);
for (int i = 0; i < 10000; i++) {
PShape dot = createShape(ELLIPSE, 0, 0, 4, 4);
dot.setStroke(false);
dot.setFill(color(255, 120, 50, 180));
cloud.addChild(dot);
}
}
void draw() {
background(10);
// Move each child shape by translating its vertex matrix
for (int i = 0; i < cloud.getChildCount(); i++) {
PShape dot = cloud.getChild(i);
// Position is set via translate on a per-shape basis — but this
// is still slower than vertex-level batching. See below for the
// vertex approach.
}
shape(cloud, 0, 0);
}
A faster approach for point-cloud particles uses a single PShape in POINTS mode:
PShape pts;
void buildShape() {
pts = createShape();
pts.beginShape(POINTS);
pts.strokeWeight(3);
for (int i = 0; i < 10000; i++) {
pts.stroke(255, 100, 50, 200);
pts.vertex(particles[i].pos.x, particles[i].pos.y);
}
pts.endShape();
}
// Rebuild each frame (still faster than 10k individual point() calls)
void draw() {
background(10);
buildShape();
shape(pts, 0, 0);
}
For static or rarely updated geometry, call buildShape() once and use pts.setVertex(i, x, y) to update positions in-place without GPU re-upload.
Avoiding Object Allocation in draw()
The Java garbage collector pauses the JVM when it runs. In a frame-rate-sensitive sketch, avoid allocating objects inside draw(). Pre-allocate everything in setup().
Bad — allocates on every frame:
void draw() {
PVector force = new PVector(0.1, 0.05); // new object every frame
for (Particle p : particles) {
p.vel.add(force);
}
}
Good — reuses a pre-allocated vector:
PVector force = new PVector(); // declared as field, allocated once
void draw() {
force.set(0.1, 0.05); // mutate in place
for (Particle p : particles) {
p.vel.add(force);
}
}
For particle systems, pre-allocate all Particle objects in setup() and recycle dead particles instead of creating new ones:
Particle[] pool = new Particle[10000];
void setup() {
for (int i = 0; i < pool.length; i++) {
pool[i] = new Particle();
}
}
void resetParticle(Particle p, float x, float y) {
p.pos.set(x, y);
p.vel.set(random(-2, 2), random(-2, 2));
p.life = 255;
}
Spatial Hashing for O(1) Neighbour Lookup
Naive nearest-neighbour search is O(N²). A spatial hash divides the world into a grid and stores each particle in the cell it occupies. Querying neighbours only checks adjacent cells.
import java.util.HashMap;
import java.util.ArrayList;
HashMap<Long, ArrayList<Integer>> grid = new HashMap<>();
float cellSize = 40;
long cellKey(float x, float y) {
int cx = (int)floor(x / cellSize);
int cy = (int)floor(y / cellSize);
return (long)cx * 1000000L + cy;
}
void buildGrid() {
grid.clear();
for (int i = 0; i < pool.length; i++) {
long key = cellKey(pool[i].pos.x, pool[i].pos.y);
if (!grid.containsKey(key)) grid.put(key, new ArrayList<Integer>());
grid.get(key).add(i);
}
}
ArrayList<Integer> getNeighbours(float x, float y) {
ArrayList<Integer> result = new ArrayList<Integer>();
int cx = (int)floor(x / cellSize);
int cy = (int)floor(y / cellSize);
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
long key = (long)(cx + dx) * 1000000L + (cy + dy);
ArrayList<Integer> cell = grid.get(key);
if (cell != null) result.addAll(cell);
}
}
return result;
}
buildGrid() runs once per frame (O(N)), and getNeighbours() is O(1) for sparse distributions.
Background Threading
CPU-heavy work (physics integration, pathfinding) can run on a Java background thread while the render thread draws the previous frame’s results. Use volatile to safely share data across threads without locks for simple flag-and-buffer patterns.
volatile boolean physicsReady = false;
PVector[] positions;
PVector[] nextPositions;
Thread physicsThread;
void setup() {
size(1280, 720, P2D);
positions = new PVector[10000];
nextPositions = new PVector[10000];
for (int i = 0; i < 10000; i++) {
positions[i] = new PVector(random(width), random(height));
nextPositions[i] = positions[i].copy();
}
physicsThread = new Thread(new PhysicsRunnable());
physicsThread.setDaemon(true);
physicsThread.start();
}
class PhysicsRunnable implements Runnable {
public void run() {
while (true) {
integrate();
physicsReady = true;
try { Thread.sleep(1); } catch (InterruptedException e) {}
}
}
void integrate() {
for (int i = 0; i < 10000; i++) {
nextPositions[i].x += random(-1.5, 1.5);
nextPositions[i].y += random(-1.5, 1.5);
nextPositions[i].x = constrain(nextPositions[i].x, 0, width);
nextPositions[i].y = constrain(nextPositions[i].y, 0, height);
}
}
}
void draw() {
if (physicsReady) {
PVector[] tmp = positions;
positions = nextPositions;
nextPositions = tmp;
physicsReady = false;
}
background(10);
stroke(255, 100, 50, 180);
strokeWeight(2);
for (PVector p : positions) {
point(p.x, p.y);
}
}
Full Example: 10,000-Particle System at 60fps
This combines PShape vertex updating, spatial hashing for neighbour avoidance, and pre-allocated vectors:
final int N = 10000;
PVector[] pos = new PVector[N];
PVector[] vel = new PVector[N];
PVector tmp = new PVector();
PShape pts;
HashMap<Long, ArrayList<Integer>> grid = new HashMap<>();
final float CELL = 30;
void setup() {
size(1280, 720, P2D);
for (int i = 0; i < N; i++) {
pos[i] = new PVector(random(width), random(height));
vel[i] = PVector.random2D().mult(random(0.5, 2.5));
}
pts = createShape();
pts.beginShape(POINTS);
pts.strokeWeight(2.5);
for (int i = 0; i < N; i++) {
pts.stroke(255, 130, 60, 180);
pts.vertex(pos[i].x, pos[i].y);
}
pts.endShape();
frameRate(60);
}
void draw() {
background(12);
buildGrid();
PVector mouse = new PVector(mouseX, mouseY);
for (int i = 0; i < N; i++) {
// Attract toward mouse
tmp.set(mouse).sub(pos[i]);
float d = tmp.mag();
tmp.normalize().mult(constrain(200 / (d * d + 1), 0, 2));
vel[i].add(tmp);
// Separation from neighbours
ArrayList<Integer> neighbours = getNeighbours(pos[i].x, pos[i].y);
for (int j : neighbours) {
if (j == i) continue;
tmp.set(pos[i]).sub(pos[j]);
float dist = tmp.mag();
if (dist < 8 && dist > 0.001) {
tmp.normalize().mult(0.5 / dist);
vel[i].add(tmp);
}
}
vel[i].limit(3.5);
pos[i].add(vel[i]);
pos[i].x = (pos[i].x + width) % width;
pos[i].y = (pos[i].y + height) % height;
pts.setVertex(i, pos[i].x, pos[i].y);
}
shape(pts, 0, 0);
surface.setTitle(nf(frameRate, 0, 1) + " fps N=" + N);
}
long cellKey(float x, float y) {
return (long)floor(x/CELL) * 100000L + (long)floor(y/CELL);
}
void buildGrid() {
grid.clear();
for (int i = 0; i < N; i++) {
long k = cellKey(pos[i].x, pos[i].y);
grid.computeIfAbsent(k, x -> new ArrayList<>()).add(i);
}
}
ArrayList<Integer> getNeighbours(float x, float y) {
ArrayList<Integer> r = new ArrayList<>();
int cx = (int)floor(x/CELL), cy = (int)floor(y/CELL);
for (int dx=-1; dx<=1; dx++) for (int dy=-1; dy<=1; dy++) {
ArrayList<Integer> c = grid.get((long)(cx+dx)*100000L+(cy+dy));
if (c != null) r.addAll(c);
}
return r;
}
On a mid-range GPU this runs comfortably above 60fps. If frame rate dips, reduce N, increase CELL (fewer neighbour checks), or skip the separation step every other frame.