Processing 4 library

Examples

35 wave shapes. One function call.
Pass a number in, get a number back. Use it for anything.

Fourteen sketches ship with the library; thirteen are previewed below. Each demonstrates a different facet: shift, morph, wild, samplers in tandem, samplers as velocity, range-mapped fields, parameter play, manual time, colour fields, periodic closure, shift-proof closing rings, phase portraits, 3D volumes. Open them all from Processing 4 > File > Examples > Contributed Libraries > waves, or copy the source below.

Live demos on this page are rendered in the browser by p5.js + p5.waves (JS). The Java code shown is what you paste into Processing to get the same result.

wave_shift

shift · one sampler · range

Filled ribbons that auto-shift between random wave formulas. Colour flows from red to blue across the strips. The output of one shift-sampler drives the ribbon height per column.

// Wave Shift
// Filled ribbons that auto-shift between random wave formulas.
// Color flows from red to blue across the strips.

import waves.*;

final int STRIPS = 20;
Waves.WaveSampler sampler;

void setup() {
  size(460, 460);
  noStroke();
  textFont(createFont("Consolas", 11));

  sampler = Waves.createSampler(new WaveOpts()
    .shift(true)
    .amplitude(1)
    .frequency(0.5f));
}

void draw() {
  background(245);
  float t = millis() / 1000.0f;
  float sw = (float)width / STRIPS;
  float cy = height / 2f;
  float rowH = height * 0.42f;

  for (int i = 0; i < STRIPS; i++) {
    float frac = i / (float)(STRIPS - 1);
    float r = 255 * (1 - frac);
    float b = 255 * frac;
    fill(r, 0, b);
    float v = sampler.sample(i * 0.4f, t + i * 0.1f);
    rect(i * sw, cy - v * rowH, sw - 1, v * rowH * 2);
  }

  fill(0);
  textSize(11);
  textAlign(LEFT);
  text(sampler.waveName(), 8, 16);
}

morph_wave

morph · two waves · sweep

A field of horizontal lines where each row blends two wave formulas. Top rows = pure wobble sine, bottom rows = pure meta sine. The blend point sweeps up and down over time, so you see the shape transform mid-field.

// Morph Wave
// A field of horizontal lines where each row blends two wave formulas.
// Top rows = pure waveA. Bottom rows = pure waveB. Middle = the morph.
// The blend sweeps up and down over time, you see the shape transform.

import waves.*;

final String WAVE_A = "wobble sine";
final String WAVE_B = "meta sine";
final int ROW_COUNT = 50;
float t = 0;

void setup() {
  size(460, 460);
}

void draw() {
  background(250);
  t += 0.0375f;

  float centre = (sin(t * 0.3f) + 1) * 0.5f;
  float rowH = (float)height / ROW_COUNT;

  for (int row = 0; row < ROW_COUNT; row++) {
    float rowFrac = row / (float)(ROW_COUNT - 1);
    float gap = abs(rowFrac - centre);
    float morphMix = constrain(1 - gap * 3, 0, 1);
    float yBase = row * rowH + rowH * 0.5f;

    float r = lerp(0, 255, morphMix);
    float b = lerp(255, 0, morphMix);

    noFill();
    stroke(r, 0, b);
    strokeWeight(1.2f + morphMix * 1.8f);
    beginShape();
    WaveOpts o = new WaveOpts()
      .wave(WAVE_A, WAVE_B)
      .mix(morphMix)
      .t(t + row * 0.06f)
      .frequency(0.08f)
      .amplitude(rowH * 2.5f);
    for (int x = 0; x < width; x += 3) {
      float waveY = Waves.wave(x, o);
      vertex(x, yBase + constrain(waveY, -rowH * 0.7f, rowH * 0.7f));
    }
    endShape();
  }

  noStroke();
  fill(0, 0, 255);
  textSize(10);
  textFont(createFont("Consolas", 10));
  textAlign(LEFT);
  text(WAVE_A, 8, 16);
  fill(255, 0, 0);
  textAlign(RIGHT);
  text(WAVE_B, width - 8, height - 8);
}

seamless_closing

shift · group: "closing" · period

A ring sampled with shift normally tears its seam open the moment it lands on a new wave — every formula has its own period. The "closing" pool fixes that: all 19 waves in it share one base period, so a sweep of ring.period() * LOBES closes through every morph. The loop keeps changing shape and never shows a seam.

// Seamless Closing
// A ring sampled with shift normally tears its seam open the moment it
// lands on a new wave — every formula has its own period. The "closing"
// pool fixes that: all 19 waves in it share one base period, so a sweep
// of ring.period() * LOBES closes through every morph. The loop keeps
// changing shape and never shows a seam.

import waves.*;

final int LOBES = 6;     // base-period repeats around the ring
final int SEGS  = 3000;  // dense enough for the shortest closing wave

Waves.WaveSampler ring;
float t = 0;

void setup() {
  size(600, 600);
  strokeJoin(ROUND);
  textFont(createFont("Consolas", 12));

  ring = Waves.createSampler(new WaveOpts()
    .shift(true)
    .shiftInterval(3)
    .shiftDuration(1.4f)
    .group("closing")
    .amplitude(1));
}

void draw() {
  background(12);
  translate(width / 2f, height / 2f);

  t += 0.02;

  // ring.period() comes straight from the library: 62.8319 for the
  // closing pool — stable through every shift. You never type it.
  float sweep  = ring.period() * LOBES;
  float radius = width * 0.28f;
  float wobble = width * 0.10f;

  // Cyan at rest, warming to pink mid-morph.
  color cold = color(60, 200, 255);
  color warm = color(255, 80, 120);
  stroke(lerpColor(cold, warm, ring.mix()));
  strokeWeight(1.6f);
  noFill();

  beginShape();
  for (int i = 0; i < SEGS; i++) {
    float frac  = i / (float)SEGS;
    float angle = frac * TWO_PI;
    float r     = radius + ring.sample(frac * sweep, t) * wobble;
    vertex(cos(angle) * r, sin(angle) * r);
  }
  endShape(CLOSE);

  // HUD
  noStroke();
  fill(220);
  textSize(12);
  text(ring.waveName() + (ring.shifting() ? "  to  " + ring.targetName() : ""),
       -width / 2f + 18, -height / 2f + 24);
}

ghost_delay

shift · group: "ghost" · period

One 1D wave, read against a delayed copy of itself. Plot sample(u) against sample(u + tau) and a single scalar wave closes into a loop ring — its phase portrait. The "ghost" pool is six closing waves hand-picked to stay clean under that pairing, and shift keeps morphing between them, so the loop family never settles. The strip along the bottom is that one wave; the dots are the two read points.

// Ghost Delay
// One 1D wave, read against a delayed copy of itself.
// x = sample(u), y = sample(u + tau): one scalar wave closes into a loop
// ring — its phase portrait. shift morphs the wave, so the loop family
// keeps changing. The strip along the bottom is that one wave; the dots
// are the two read points.

import waves.*;

final int N = 800;

Waves.WaveSampler sampler;

void setup() {
  size(720, 720);
  colorMode(HSB, 360, 100, 100, 100);
  strokeJoin(ROUND);
  textFont(createFont("Consolas", 12));

  // "ghost" is a built-in pool of closing waves that stay clean under this
  // delay. range(-1, 1) gives unit output (the default would be amplitude 100).
  sampler = Waves.createSampler(new WaveOpts()
    .group("ghost")
    .shift(true)
    .range(-1, 1));
}

void draw() {
  background(230, 25, 8);
  noFill();

  float t   = millis() / 1000.0f;
  float hue = (t * 12) % 360;

  float period = sampler.period();                      // ghost waves share one period
  float tau    = period * (0.5f + 0.35f * sin(t * 0.35f));  // the ghost delay, breathing

  // The ring: the wave against its own delayed self.
  float rad = min(width, height) * 0.36f;
  stroke(hue, 55, 100, 90);
  strokeWeight(1.4f);
  pushMatrix();
  translate(width / 2f, height / 2f - 30);
  beginShape();
  for (int i = 0; i <= N; i++) {
    float u = (i / (float)N) * period;                  // one period -> the ring closes
    vertex(sampler.sample(u, t) * rad, sampler.sample(u + tau, t) * rad);
  }
  endShape(CLOSE);
  popMatrix();

  drawWaveStrip(t, tau, hue, period);

  // shift is cycling the ghost pool; name the current wave.
  noStroke();
  fill(0, 0, 70);
  textSize(12);
  text(sampler.waveName(), 18, 26);
}

// The raw 1D wave as a height line, with the two read points (u and u + tau)
// marked. The whole trick is reading this one wave twice.
void drawWaveStrip(float t, float tau, float hue, float period) {
  float baseY = height - 60;
  float w     = width - 120;
  float left  = 60;
  float amp   = 24;

  noFill();
  stroke(hue, 45, 100, 70);
  strokeWeight(1.2f);
  beginShape();
  for (int i = 0; i <= N; i++) {
    float u = (i / (float)N) * period;
    vertex(left + (i / (float)N) * w, baseY - sampler.sample(u, t) * amp);
  }
  endShape();

  noStroke();
  fill(0, 0, 100);
  circle(left, baseY - sampler.sample(0, t) * amp, 7);
  fill(hue, 70, 100);
  circle(left + (tau % period) / period * w, baseY - sampler.sample(tau, t) * amp, 7);
}

flow_fields

ASCII · one sampler · direction

A grid of ASCII characters forms a flow field. Each cell's direction comes from a single shift-sampler, like noise, but with structure. The wave shifts every few seconds, so the whole field's character keeps changing.

// Flow Fields
// A grid of ASCII characters forms a flow field.
// Each cell's direction comes from waves. Like noise, but with structure.
// The wave formula shifts automatically every few seconds.

import waves.*;

final int COLS = 30;
final int ROWS = 30;
final String[] DIRS = { "-", "/", "|", "\\" };

Waves.WaveSampler sampler;

void setup() {
  size(460, 460);
  textFont(createFont("Consolas", 14));
  textAlign(CENTER, CENTER);
  noStroke();
  fill(0);

  sampler = Waves.createSampler(new WaveOpts()
    .shift(true)
    .shiftInterval(4)
    .shiftDuration(2)
    .frequency(2)
    .range(-1, 1));
}

void draw() {
  background(255);
  float t = millis() / 1000.0f;
  float sz = (float)width / COLS;
  textSize(sz * 0.9f);

  for (int row = 0; row < ROWS; row++) {
    for (int col = 0; col < COLS; col++) {
      float val = sampler.sample(col * 0.5f, t + row * 0.4f);
      int idx = constrain((int)map(val, -1, 1.001f, 0, 4), 0, 3);
      text(DIRS[idx], col * sz + sz / 2, row * sz + sz / 2);
    }
  }
}

binary_field

two samplers · sum · threshold

Two independent shift-samplers, summed per cell, thresholded into a 2D pattern. Both samplers shift on their own schedule, so the field's character keeps evolving: sometimes interference, sometimes stripes, sometimes checker.

// Binary Field
// Two samplers, summed per cell, thresholded into a 2D pattern.
// Both samplers shift independently, so the field's character keeps
// evolving. Sometimes interference, sometimes stripes, sometimes checker.

import waves.*;

final int COLS = 30;
final int ROWS = 20;

Waves.WaveSampler rowS, colS;

void setup() {
  size(460, 460);
  noStroke();
  textFont(createFont("Consolas", 11));

  rowS = Waves.createSampler(new WaveOpts()
    .shift(true)
    .shiftInterval(4)
    .shiftDuration(1)
    .range(-1, 1)
    .seed(1));

  colS = Waves.createSampler(new WaveOpts()
    .shift(true)
    .shiftInterval(4.5f)
    .shiftDuration(1.2f)
    .range(-1, 1)
    .seed(2));
}

void draw() {
  background(245);
  float t = millis() / 1000.0f;
  float labelH = 28;
  float cw = (float)width / COLS;
  float ch = (height - labelH) / ROWS;
  float offset = t * 0.4f;

  for (int row = 0; row < ROWS; row++) {
    float rv = rowS.sample(row * 5 + offset, t);
    for (int col = 0; col < COLS; col++) {
      float cv = colS.sample(col * 2.5f - offset, t);
      boolean on = (rv + cv) > 0;
      fill(on ? 15 : 230);
      rect(col * cw, labelH + row * ch, cw - 0.5f, ch - 0.5f);
    }
  }

  fill(40);
  textSize(11);
  textAlign(LEFT, CENTER);
  text(rowS.waveName() + "  x  " + colS.waveName(), 8, labelH / 2);
}

random_walker

wave as velocity · trails · PGraphics

No angles, no steps. Wave output IS the velocity. Five trails ride two shift-samplers as raw displacement. When the formulas shift, the movement character transforms entirely. Trails draw onto a PGraphics with low-alpha fade.

// Not So Random Walker
// No angles, no steps. Wave output IS the velocity.
// Five trails ride two shift-samplers as raw displacement.
// When formulas shift, movement character transforms entirely.

import waves.*;

final int WALKERS = 5;
Waves.WaveSampler xWave, yWave;
float[] wx = new float[WALKERS], wy = new float[WALKERS];
float[] prevX = new float[WALKERS], prevY = new float[WALKERS];
float t = 0;
PGraphics trail;

// R, G, B, yellow, purple
final int[][] palette = {
  {255, 60, 60},
  {60, 220, 60},
  {60, 100, 255},
  {255, 220, 40},
  {180, 60, 255}
};

void setup() {
  size(460, 460);
  trail = createGraphics(460, 460);
  trail.beginDraw();
  trail.background(15);
  trail.endDraw();

  xWave = Waves.createSampler(new WaveOpts()
    .shift(true)
    .shiftInterval(4)
    .shiftDuration(1.5f)
    .amplitude(2.5f)
    .frequency(0.7f)
    .seed(0));

  yWave = Waves.createSampler(new WaveOpts()
    .shift(true)
    .shiftInterval(5)
    .shiftDuration(1.2f)
    .amplitude(2.5f)
    .frequency(0.55f)
    .seed(77));

  for (int i = 0; i < WALKERS; i++) {
    float a = TWO_PI * i / WALKERS;
    wx[i] = width / 2f + cos(a) * 40;
    wy[i] = height / 2f + sin(a) * 40;
    prevX[i] = wx[i];
    prevY[i] = wy[i];
  }
}

void draw() {
  trail.beginDraw();
  trail.noStroke();
  trail.fill(15, 15, 15, 8);
  trail.rect(0, 0, trail.width, trail.height);

  t += 0.025f;

  for (int i = 0; i < WALKERS; i++) {
    float phase = i * 6.7f;

    float vx = xWave.sample(t * 1.8f + phase, t);
    float vy = yWave.sample(t * 2.1f + phase * 1.3f, t);

    prevX[i] = wx[i];
    prevY[i] = wy[i];

    wx[i] += vx;
    wy[i] += vy;

    if (wx[i] < 0)      wx[i] += width;
    if (wx[i] > width)  wx[i] -= width;
    if (wy[i] < 0)      wy[i] += height;
    if (wy[i] > height) wy[i] -= height;

    if (abs(wx[i] - prevX[i]) > width / 2)  continue;
    if (abs(wy[i] - prevY[i]) > height / 2) continue;

    int[] col = palette[i];
    trail.stroke(col[0], col[1], col[2], 200);
    trail.strokeWeight(2.5f);
    trail.line(prevX[i], prevY[i], wx[i], wy[i]);
  }
  trail.endDraw();

  image(trail, 0, 0);

  noStroke();
  fill(255, 255, 255, 120);
  textSize(10);
  textFont(createFont("Consolas", 10));
  textAlign(LEFT);
  text(xWave.waveName() + " x " + yWave.waveName(), 8, 16);
}

wave_params

interactive · frequency · amplitude

Eight layered wave lines. Mouse X drives frequency, mouse Y drives amplitude, and the formula itself shifts automatically. Drag across the canvas to feel what each parameter does to the shape.

// Shape Parameters
// mouseX = frequency, mouseY = amplitude.
// Wave formula shifts automatically.
// Drag to feel what each parameter does.

import waves.*;

final int LINE_COUNT = 8;

Waves.WaveSampler shiftSampler;
WaveOpts o;
float t = 0;

void setup() {
  size(460, 460);
  textFont(createFont("Consolas", 10));

  // Only used to get shifting wave names
  shiftSampler = Waves.createSampler(new WaveOpts()
    .shift(true)
    .shiftInterval(4)
    .shiftDuration(1.5f)
    .seed(42));

  o = new WaveOpts();
}

void draw() {
  background(245);
  t += 0.015f;

  // Mouse drives parameters
  float freq = constrain(map(mouseX, 0, width, 0.15f, 3.5f), 0.15f, 3.5f);
  float amp  = constrain(map(mouseY, 0, height, 120, 8), 8, 120);

  // Ping the sampler to keep shift state updated
  shiftSampler.sample(0, t);
  String waveName = shiftSampler.waveName();

  // Draw layered lines — each offset in phase for depth
  for (int i = 0; i < LINE_COUNT; i++) {
    float progress = i / (float)(LINE_COUNT - 1);
    float phase    = i * 0.7f;
    float lineAmp  = amp * (1 - progress * 0.5f);
    float gray     = lerp(0, 200, progress);
    float weight   = lerp(2.5f, 0.8f, progress);

    stroke(gray);
    strokeWeight(weight);
    noFill();
    o.wave(waveName)
     .t(t)
     .amplitude(lineAmp)
     .frequency(freq * 0.01f)
     .phase(phase);
    beginShape();
    for (int x = 0; x <= width; x += 3) {
      float val = Waves.wave(x, o);
      vertex(x, height / 2f + val);
    }
    endShape();
  }

  // Labels
  noStroke();
  fill(0);
  textSize(10);
  textAlign(LEFT, TOP);
  text(waveName, 8, 8);

  textAlign(LEFT, BOTTOM);
  text("frequency: " + nf(freq, 1, 2), 8, height - 8);

  textAlign(RIGHT, BOTTOM);
  text("amplitude: " + round(amp), width - 8, height - 8);

  // Crosshair
  stroke(0, 20);
  strokeWeight(0.5f);
  line(mouseX, 0, mouseX, height);
  line(0, mouseY, width, mouseY);
}

wild_mode

wild mode · unpredictability · interactive

A grid of circles split down the middle. Left half: the wave as written. Right half: the same wave in wild mode. Mouse X is the unpredictability dial — drag right and feel the chaos build.

// Wild Mode
// Grid of circles: left half stable, right half wild.
// mouseX controls unpredictability — drag to feel the chaos build.

import waves.*;

final int COLS = 20;
final int ROWS = 14;

float t = 0;
int wildWave;
WaveOpts stableOpts, wildOpts;

void setup() {
  size(460, 460);
  noStroke();
  textFont(createFont("Consolas", 10));
  textAlign(CENTER);
  wildWave = (int)random(Waves.count());

  float cw = (float)width / COLS;
  float ch = (height - 24) / (float)ROWS;
  float maxR = min(cw, ch) * 0.44f;

  stableOpts = new WaveOpts().wave(wildWave).range(3, maxR);
  wildOpts   = new WaveOpts().wave(wildWave).range(3, maxR).mode("wild");
}

void draw() {
  background(238);
  t += 0.012f;
  float cw = (float)width / COLS;
  float ch = (height - 24) / (float)ROWS;
  int half = COLS / 2;
  float unpred = constrain(map(mouseX, 0, width, 0, 1), 0, 1);

  stableOpts.t(t);
  wildOpts.t(t).unpredictability(unpred);

  fill(0);
  for (int row = 0; row < ROWS; row++) {
    for (int col = 0; col < COLS; col++) {
      float cx = (col + 0.5f) * cw;
      float cy = (row + 0.5f) * ch;
      float coord = col * 0.15f + row * 0.3f;
      WaveOpts o = (col < half) ? stableOpts : wildOpts;
      float sz = Waves.wave(coord, o);
      circle(cx, cy, sz * 2);
    }
  }

  stroke(0, 30);
  strokeWeight(1);
  line(width / 2f, 0, width / 2f, height - 24);
  noStroke();
  fill(0);
  textSize(10);
  text("stable", width / 4f, height - 6);
  text("wild  " + nf(unpred, 1, 2), 3 * width / 4f, height - 6);
}

time_strata

manual time · scrub · HSB ribbons

Time is a plain number you pass in — the library never calls a clock. Sixteen filled ribbons, each frozen at its own moment in a 24-second window. Mouse X scrubs through the whole window like reading geological strata.

// Time Strata
// Time is a plain number — full manual control.
// Mouse X scrubs a time window; each layer is frozen at its own t.
// Layers are filled ribbons with HSB color, creating geological strata.

import waves.*;

final int   LAYERS      = 16;
final float WAVE_WINDOW = 6;

WaveOpts top, bottom;

void setup() {
  size(460, 460);
  colorMode(HSB, 360, 100, 100, 255);
  textFont(createFont("Consolas", 10));
  top    = new WaveOpts().wave("classic sine").amplitude(25);
  bottom = new WaveOpts().wave("classic sine").amplitude(15);
}

void draw() {
  background(0, 0, 96);
  float timeBase = map(mouseX, 0, width, 0, 24);

  noStroke();
  for (int i = LAYERS - 1; i >= 0; i--) {
    float layerT   = timeBase + (i / (float)LAYERS) * WAVE_WINDOW;
    float y0       = map(i, 0, LAYERS - 1, 50, height - 50);
    float wHue     = (i * 22) % 360;
    float alphaVal = map(i, 0, LAYERS - 1, 200, 60);
    float freq     = 0.6f + i * 0.08f;

    fill(wHue, 70, 85, alphaVal);
    top.t(layerT).frequency(freq);
    bottom.t(layerT + 0.3f).frequency(freq);

    beginShape();
    for (int x = 0; x <= width; x += 3) {
      float dy = Waves.wave(x * 0.15f, top);
      vertex(x, y0 + dy);
    }
    for (int x2 = width; x2 >= 0; x2 -= 3) {
      float dy2 = Waves.wave(x2 * 0.15f, bottom);
      vertex(x2, y0 + dy2 + 22);
    }
    endShape(CLOSE);
  }

  // time cursor label
  fill(0);
  noStroke();
  textSize(10);
  textAlign(LEFT);
  text("t = " + nf(timeBase, 1, 2), 12, 20);
  text("move mouse", 12, 34);
}

color_field

six samplers · groups · colour

A wave-driven take on random(155) + 100. Three slow base samplers set a per-channel floor; three faster field samplers add 0–100 on top, per cell. Every channel drifts inside a moving 100-wide window — static, but organised.

// Static Field
// A wave-driven take on `random(155) + 100`.
// Three slow base samplers set a per-channel floor in [0, 155].
// Three faster field samplers add 0 to 100 on top, per cell.
// Each channel stays inside a moving 100-wide window that drifts and morphs.

import waves.*;

Waves.WaveSampler baseR, baseG, baseB;
Waves.WaveSampler fieldR, fieldG, fieldB;

void setup() {
  size(460, 460);
  noStroke();
  textFont(createFont("Consolas", 11));

  baseR = Waves.createSampler(new WaveOpts()
    .shift(true).group("gentle")
    .range(0, 155).frequency(0.20f)
    .shiftInterval(7).shiftDuration(2));
  baseG = Waves.createSampler(new WaveOpts()
    .shift(true).group("gentle")
    .range(0, 155).frequency(0.17f)
    .shiftInterval(8).shiftDuration(2));
  baseB = Waves.createSampler(new WaveOpts()
    .shift(true).group("gentle")
    .range(0, 155).frequency(0.13f)
    .shiftInterval(9).shiftDuration(2));

  fieldR = Waves.createSampler(new WaveOpts()
    .shift(true)
    .group(new String[]{ "classic sine", "triangle", "bumpy sine", "mountain peaks", "wobble sine" })
    .range(0, 1).frequency(0.08f)
    .shiftInterval(4).shiftDuration(1.5f));
  fieldG = Waves.createSampler(new WaveOpts()
    .shift(true)
    .group(new String[]{ "sine", "triangle", "squared sine", "valleys", "round linked sine" })
    .range(0, 1).frequency(0.06f)
    .shiftInterval(5).shiftDuration(1.5f));
  fieldB = Waves.createSampler(new WaveOpts()
    .shift(true)
    .group(new String[]{ "classic sine", "sharp peaks", "bumpy sine", "half sine", "smooth solid sine" })
    .range(0, 1).frequency(0.07f)
    .shiftInterval(6).shiftDuration(1.5f));
}

void draw() {
  background(245);
  int   cell = 8;
  int   top  = 30;
  float t    = millis() / 1000.0f;

  int r0 = floor(constrain(baseR.sample(0,   t), 0, 155));
  int g0 = floor(constrain(baseG.sample(100, t), 0, 155));
  int b0 = floor(constrain(baseB.sample(200, t), 0, 155));

  for (int y = top; y < height; y += cell) {
    for (int x = 0; x < width; x += cell) {
      float rp = constrain(fieldR.sample(x + y * 0.35f,        t), 0, 1);
      float gp = constrain(fieldG.sample(x * 0.3f + y,         t), 0, 1);
      float bp = constrain(fieldB.sample(x * 0.7f - y * 0.25f, t), 0, 1);

      rp = lerp(rp, random(1), 0.15f);
      gp = lerp(gp, random(1), 0.15f);
      bp = lerp(bp, random(1), 0.15f);

      fill(r0 + rp * 100, g0 + gp * 100, b0 + bp * 100);
      rect(x, y, cell, cell);
    }
  }

  fill(20);
  textSize(11);
  textAlign(LEFT, CENTER);
  text("R " + r0 + "-" + (r0 + 100) +
       "   G " + g0 + "-" + (g0 + 100) +
       "   B " + b0 + "-" + (b0 + 100), 8, 12);
}

spiky_lissajous

periodicity · closed path · interactive

A classic 3:5 Lissajous curve, but sin() is replaced by a spiky wave formula. The pen traces one period-exact cycle and lands back on the start marker — proof that wave(0) == wave(A × period), so even sawtooth paths close. Click the canvas to cycle waves.

// Spiky Lissajous
// A classic a:b Lissajous curve, but sin() is replaced by a spiky
// wave formula. The pen traces the curve over one frozen-phase cycle,
// then lands exactly back on the start marker — proof that even with
// spikes or sawtooth edges, wave(0) == wave(A * period) for integer A,
// so the path closes.

import waves.*;

final String[] SPIKY_NAMES   = { "sharp peaks", "batman", "zig-zag sine", "up down pulse" };
final float[]  SPIKY_PERIODS = { PI * 10, PI * 20, PI * 10, PI * 10 };
final int[][]  SPIKY_COLORS  = {
  { 70, 220, 130 },
  { 255, 70, 70 },
  { 60, 160, 255 },
  { 250, 220, 40 }
};

final int RATIO_A  = 3;
final int RATIO_B  = 5;
final int SEGMENTS = 1400;
final int CYCLE_MS = 4500;

int waveIdx = 0;
float[] xs = new float[SEGMENTS + 1];
float[] ys = new float[SEGMENTS + 1];
WaveOpts o = new WaveOpts().amplitude(1);

void setup() {
  size(720, 720);
  strokeJoin(ROUND);
  noFill();
  textFont(createFont("Consolas", 12));
}

void draw() {
  background(12);
  translate(width / 2f, height / 2f);

  String name   = SPIKY_NAMES[waveIdx];
  float  period = SPIKY_PERIODS[waveIdx];
  int[]  col    = SPIKY_COLORS[waveIdx];
  float  radius = min(width, height) * 0.34f;

  // Freeze phase for one cycle so the pen draws a genuine closed loop
  // and literally returns to (xs[0], ys[0]) at prog = 1.
  int   cycleId  = millis() / CYCLE_MS;
  float frozenMs = cycleId * (float)CYCLE_MS;
  float phaseX   = frozenMs * 0.00015f;
  float phaseY   = frozenMs * 0.00021f;
  float prog     = (millis() % CYCLE_MS) / (float)CYCLE_MS;
  int   drawnTo  = floor(prog * SEGMENTS);

  o.wave(name);
  for (int i = 0; i <= SEGMENTS; i++) {
    float theta = (i / (float)SEGMENTS) * period;
    xs[i] = radius * Waves.wave(RATIO_A * theta + phaseX * period, o);
    ys[i] = radius * Waves.wave(RATIO_B * theta + phaseY * period + period * 0.25f, o);
  }

  // Dim ghost of the full closed loop — proves the target doesn't move.
  noFill();
  stroke(col[0] * 0.22f, col[1] * 0.22f, col[2] * 0.22f);
  strokeWeight(1);
  beginShape();
  for (int i = 0; i <= SEGMENTS; i++) vertex(xs[i], ys[i]);
  endShape(CLOSE);

  // Bright pen trail up to the current progress.
  stroke(col[0], col[1], col[2]);
  strokeWeight(1.4f);
  beginShape();
  for (int i = 0; i <= drawnTo; i++) vertex(xs[i], ys[i]);
  endShape();

  // Start marker — the "home" the pen must return to.
  float homeR = 12 + sin(prog * TWO_PI) * 1.5f;
  noStroke();
  fill(255); circle(xs[0], ys[0], homeR);
  fill(18);  circle(xs[0], ys[0], homeR - 6);

  // Pen cursor — the moving tip.
  fill(255);
  circle(xs[drawnTo], ys[drawnTo], 8);

  // HUD
  noStroke();
  fill(220);
  textSize(12);
  text(name + "  /  ratio " + RATIO_A + ":" + RATIO_B,
       -width / 2f + 18, -height / 2f + 24);
  fill(120);
  textSize(10);
  text("click to cycle wave  /  the pen returns to the white dot",
       -width / 2f + 18, -height / 2f + 42);
}

void mousePressed() {
  waveIdx = (waveIdx + 1) % SPIKY_NAMES.length;
}

wave_volume_3d

P3D · three samplers · volume

A 16×16×16 point volume in P3D. Three desynchronised shift-samplers — one per axis — make the glowing surface breathe, drift sideways, and pulse in depth. The volume turns on its own; drag to rotate by hand.

// 3D Wave Volume (P3D)
// 16x16 grid with 3 shift-samplers — one per axis.
// Every axis breathes independently, creating a living 3D volume.
// Drag to rotate by hand; the volume also turns on its own.

import waves.*;

final int   N       = 16;
final float SPACING = 22;
final float HALF    = (N - 1) * SPACING / 2;

Waves.WaveSampler samplerY, samplerX, samplerZ;
float rotAngle = 0;
float tilt     = -0.5f;

void setup() {
  size(460, 460, P3D);
  frameRate(30);
  noFill();
  strokeWeight(4);

  // Three desynchronised shift-samplers — each axis has its own wave life
  samplerY = Waves.createSampler(new WaveOpts()
    .shift(true).shiftInterval(3).shiftDuration(1.5f)
    .range(-6, 6).frequency(0.4f).seed(0));

  samplerX = Waves.createSampler(new WaveOpts()
    .shift(true).shiftInterval(4).shiftDuration(1.2f)
    .range(-3, 3).frequency(0.3f).seed(42));

  samplerZ = Waves.createSampler(new WaveOpts()
    .shift(true).shiftInterval(5).shiftDuration(1)
    .range(-3, 3).frequency(0.35f).seed(77));
}

void draw() {
  background(12);
  translate(width / 2f, height / 2f);
  rotateX(tilt);
  rotAngle += 0.005f;
  rotateY(rotAngle);

  float t = millis() / 1000.0f;

  beginShape(POINTS);
  for (int xi = 0; xi < N; xi++) {
    for (int zi = 0; zi < N; zi++) {
      // Y: main surface displacement — full range across the cube
      float dy = samplerY.sample(xi * 0.9f + zi * 0.6f, t);

      // X: horizontal breathing — points drift sideways
      float dx = samplerX.sample(zi * 0.8f + xi * 0.3f, t * 0.85f);

      // Z: depth warping — grid pulses in and out
      float dz = samplerZ.sample(xi * 0.7f + zi * 0.5f, t * 0.7f);

      // Map dy to a grid row and spread points around the surface
      float surfaceRow = N / 2f + dy;
      float thick = 2.5f;

      for (int yi = 0; yi < N; yi++) {
        float gap = abs(yi - surfaceRow);
        if (gap < thick) {
          float glow   = 1 - gap / thick;
          int   bright = round(80 + (yi / (float)(N - 1)) * 175);
          stroke(bright, bright, 255, 255 * glow);
          vertex(
            -HALF + xi * SPACING + dx * SPACING * 0.4f,
            -HALF + yi * SPACING,
            -HALF + zi * SPACING + dz * SPACING * 0.4f
          );
        }
      }
    }
  }
  endShape();
}

// Drag to rotate the volume by hand
void mouseDragged() {
  rotAngle += (mouseX - pmouseX) * 0.01f;
  tilt     += (mouseY - pmouseY) * 0.01f;
}