Processing Expert Article 5

Performance Optimisation

Push Processing to 60fps with 10,000 particles by mastering renderer selection, PShape batching, spatial hashing, and background threading.

⏱ 55 min performance PShape spatial hashing threading P2D particles

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 PShape batching.
  • 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.