Drop two pebbles into water and the ripples overlap. Where two crests meet, they reinforce each other; where a crest meets an equally strong trough, they cancel. In this SwingLoops example, we use two sinusoidal waves to recreate that pattern pixel by pixel.
The result is a portrait animation with cyan crests, pink troughs, and dark bands where the combined displacement is small. The example uses Java Swing and Java 2D without external libraries.
Source code
Save the following code as RippleInterference.java:
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
public class RippleInterference extends JPanel {
private static final int W = 180, H = 320;
private static final int AX = 52, BX = 128, SY = 160;
private static final double TAU = 2 * Math.PI;
private static final double CYCLE = 8.0;
private static final Color BACKGROUND = new Color(6, 12, 26);
private final BufferedImage image =
new BufferedImage(W, H, BufferedImage.TYPE_INT_RGB);
private final int[] pixels =
((DataBufferInt) image.getRaster().getDataBuffer()).getData();
private final float[] sine = new float[W * H];
private final float[] cosine = new float[W * H];
private final Timer timer = new Timer(33, _ -> repaint());
private long start;
public RippleInterference() {
setPreferredSize(new Dimension(450, 800));
setBackground(BACKGROUND);
for (int y = 0; y < H; y++) {
for (int x = 0; x < W; x++) {
int i = y * W + x;
double a = Math.hypot(x - AX, y - SY);
double b = Math.hypot(x - BX, y - SY);
double ka = TAU * a / 21, kb = TAU * b / 21;
double wa = 1 / (1 + a / 110);
double wb = 1 / (1 + b / 110);
sine[i] = (float) (wa * Math.sin(ka) + wb * Math.sin(kb));
cosine[i] = (float) (wa * Math.cos(ka) + wb * Math.cos(kb));
}
}
}
@Override
public void addNotify() {
super.addNotify();
start = System.nanoTime();
timer.start();
}
@Override
public void removeNotify() {
timer.stop();
super.removeNotify();
}
private void render(double seconds) {
double phase = TAU * 5 * ((seconds % CYCLE) / CYCLE);
double c = Math.cos(phase), s = Math.sin(phase);
for (int i = 0; i < pixels.length; i++) {
// sin(k*d - phase), summed across both sources.
double wave = sine[i] * c - cosine[i] * s;
double glow = Math.min(1, Math.abs(wave) / 1.5);
glow *= glow;
int r = 6 + (int) (glow * (wave >= 0 ? 32 : 238));
int g = 12 + (int) (glow * (wave >= 0 ? 222 : 98));
int b = 26 + (int) (glow * (wave >= 0 ? 229 : 143));
pixels[i] = (r << 16) | (g << 8) | b;
}
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
drawFrame(graphics, getWidth(), getHeight(),
(System.nanoTime() - start) / 1e9);
}
void drawFrame(Graphics graphics, int width, int height, double seconds) {
render(seconds);
Graphics2D g = (Graphics2D) graphics.create();
try {
double scale = Math.min(width / (double) W,
height / (double) H);
g.translate((width - W * scale) / 2,
(height - H * scale) / 2);
g.scale(scale, scale);
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(image, 0, 0, null);
g.setColor(new Color(237, 249, 255));
g.fillOval(AX - 2, SY - 2, 4, 4);
g.fillOval(BX - 2, SY - 2, 4, 4);
} finally {
g.dispose();
}
}
static void main() {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("SwingLoops - Ripple Interference");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setContentPane(new RippleInterference());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
});
}
}
Run the example
From the directory containing the source file, run:
javac RippleInterference.java
java RippleInterference
The default window is 450 × 800 pixels. Resize it to see the pattern scale while keeping its aspect ratio. Close the window to exit.
Calculate a ripple at each pixel
A circular wave depends on how far the pixel is from its source. The program finds that distance with Math.hypot():
double a = Math.hypot(x - AX, y - SY);
double ka = TAU * a / 21;
double wa = 1 / (1 + a / 110);
Here, TAU is one full turn in radians, or 2π. Dividing by 21 means the spatial phase advances by one turn every 21 image pixels. The factor wa makes the amplitude smaller as distance increases.
A source contributes wa × sin(ka − phase). As the shared time phase increases, points of equal phase move outward from the source. The program adds this contribution to the corresponding contribution from the second source.
The waves have the same frequency and start in phase. If the difference between the two distances is a whole wavelength, their oscillations reinforce each other. At half a wavelength, one oscillation opposes the other. Their amplitudes may differ because of the distance weights, so those opposing waves can leave a small residual instead of canceling completely.
Cache the parts that depend on position
Calculating each distance and calling sine twice for every pixel on every repaint would repeat work. The sources stay fixed, so the code uses the identity:
sin(k − p) = sin(k) cos(p) − cos(k) sin(p)
For each pixel, the constructor caches the sum of the two weighted spatial sines and the sum of the two weighted spatial cosines. During painting, it only needs the sine and cosine of the time phase:
double c = Math.cos(phase), s = Math.sin(phase);
double wave = sine[i] * c - cosine[i] * s;
The same values of c and s apply to all 57,600 pixels. With five turns of phase per eight-second interval, each oscillation takes 1.6 seconds.
Convert displacement to color
The sign selects cyan or pink. The magnitude controls how bright that color becomes:
double glow = Math.min(1, Math.abs(wave) / 1.5);
glow *= glow;
Squaring the normalized magnitude makes weaker values darker and emphasizes stronger peaks and troughs. A dark pixel shows low displacement at that instant; the dark lanes that persist as the ripples move reveal where the waves repeatedly oppose each other.
The renderer writes each color into an integer-backed BufferedImage and then scales the finished image with bilinear interpolation. Working at 180 × 320 keeps the per-frame calculations small while the window can display the image at a larger size.
Animate the result with Swing
A Swing timer requests a repaint approximately every 33 milliseconds. The animation reads time with System.nanoTime() instead of counting repaint events, so a late frame does not slow the intended wave motion. The window and timer run on Swing’s event-dispatch thread.
The renderer is also available through drawFrame(), which accepts a time in seconds. That makes it possible to render the same pattern into an image at a chosen instant without opening the Swing window.
Try changing the pattern
Move the sources closer together and compare the spacing of the dark bands. Then change the wavelength from 21 to 28 in both phase calculations. Keeping everything else fixed lets you see how the spacing changes with wavelength.
The complete Java source is free to copy and run. Explore more examples in the SwingLoops repository.





