I'm writing my own version of a Mandelbrot set generator, with pan and zoom functionality. (There's not yet a color overlay of sets generated with various beta values; the beta value is changed manually with a spinner.)
- Pan: drag left-click
- Zoom: roll mouse wheel
The main problem with this code is that after zooming in ~500 times, the generated shape will jump around on the canvas, rather than staying centered on the mouse cursor. I've tried debugging and the best idea I have is that this error is due to the limits of floating-point precision.
(As a self-taught Java programmer) I'm looking for suggestions regarding:
- How to zoom in indefinitely without losing precision
- Improving performance
- Class structure and overall code style
- Any other improvements
Mandelbrot.java:
import java.awt.geom.Point2D;
public class Mandelbrot
{
private int beta = 50;
private volatile boolean abort = false;
private volatile boolean generating = false;
private volatile int genCount = 0;
private Mandelbrot()
{
new GUI(this);
}
/**
* @return Whether the given complex number diverges from the origin-centered
* circle of radius 2 after {@code beta} number of tries.
*/
private boolean diverges(double real, double complex)
{
ComplexNumber cn = new ComplexNumber(real, complex);
ComplexNumber z = new ComplexNumber(0);
for(int i = 0; i < beta; i++)
{
z.add(z.square(), cn);
if(z.real*z.real + z.complex*z.complex > 4)
return true;
}
return false;
}
private void generatePoints(Point2D.Double tl, Point2D.Double br, int[] pixels, int threadID)
{
// final long startTime = System.nanoTime();
Point2D.Double start = GUI.wtc(tl);
Point2D.Double end = GUI.wtc(br);
double increment = (end.x - start.x)/(double)(GUI.SIZE - 1);
for(double y = start.y, cy = 0; cy < GUI.SIZE; y += increment, cy++)
{
// Stop computing if a new zoom/pan is commanded
if(abort)
{
abort = false;
System.out.printf("(%d) Aborting at cy %d\n", genCount, (int)cy);
break;
}
for(double cx = 0, x = start.x; cx < GUI.SIZE; x += increment, cx++)
{
if(x*x + y*y > 4)
continue;
if(!diverges(x, y))
pixels[(int) (cy*GUI.SIZE + cx)] = 0xFF000000;
}
}
// long elapsedTime = System.nanoTime() - startTime;
// System.out.printf("thread %d time: %.3fs\n", threadID, elapsedTime / 1000000000.0);
}
int[] generatePoints(Point2D.Double tl, Point2D.Double br)
{
System.out.printf("(%d) Generating on thread: %s\n",
genCount, Thread.currentThread().getName());
long startTime = System.nanoTime();
int[] pixels = new int[GUI.SIZE*GUI.SIZE];
generating = true;
//TODO multithreaded Mandelbrot calculation
generatePoints(tl, br, pixels, 0);
generating = false;
long elapsedTime = System.nanoTime() - startTime;
System.out.printf("(" + genCount++ + ") Done. Took %.3fs\n\n", elapsedTime / 1000000000.0);
return pixels;
}
void abortProcessing()
{
if(generating)
{
abort = true;
}
}
void setBeta(int beta)
{
this.beta = beta;
}
int getBeta()
{
return beta;
}
public static void main(String[] args)
{
new Mandelbrot();
}
}
GUI.java:
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.awt.geom.AffineTransform;
import java.awt.geom.Ellipse2D;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.SpinnerModel;
import javax.swing.SpinnerNumberModel;
import javax.swing.WindowConstants;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
@SuppressWarnings("serial")
final class GUI
{
public static final int SIZE = 992; // Must be divisible by 4
public static final double ZOOM_FACTOR = 1.1;
private BufferedImage image = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB);
private Canvas canvas;
GUI(Mandelbrot man)
{
canvas = new Canvas(man);
refresh(man);
JFrame frame = new JFrame("MandelBrot Set Viewer");
frame.setContentPane(setupPanel(man));
frame.pack();
// frame.setLocation(5456, 5);
// frame.setLocation(4200, 975);
// frame.setLocation(4200, 5);
frame.setVisible(true);
frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
}
/**
* Converts from window coordinates to Cartesian coordinates.
*/
public static Point2D.Double wtc(Point2D.Double window)
{
return new Point2D.Double((window.x * 4 / (double)GUI.SIZE) - 2,
(window.y * 4 / (double)GUI.SIZE) - 2 );
}
private JPanel setupPanel(final Mandelbrot man)
{
SpinnerModel betaModel = new SpinnerNumberModel(man.getBeta(), 1, Integer.MAX_VALUE, 1);
JSpinner betaSpinner = new JSpinner(betaModel);
betaSpinner.addChangeListener(new ChangeListener()
{
@Override
public void stateChanged(ChangeEvent e)
{
man.setBeta((int)((JSpinner) e.getSource()).getValue());
refresh(man);
}
});
JLabel betaLabel = new JLabel("Beta value:");
JPanel betaPanel = new JPanel();
betaPanel.add(betaLabel);
betaPanel.add(betaSpinner);
JButton resetButton = new JButton("Reset");
resetButton.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent arg0)
{
canvas.reset();
}
});
JPanel resetPanel = new JPanel();
resetPanel.add(resetButton);
JPanel sidePanel = new JPanel(new BorderLayout(10, 10));
sidePanel.add(betaPanel, BorderLayout.NORTH);
sidePanel.add(resetPanel, BorderLayout.SOUTH);
JPanel mainPanel = new JPanel();
mainPanel.add(sidePanel);
mainPanel.add(canvas);
return mainPanel;
}
void refresh(Mandelbrot man)
{
//TODO use a Thread pool rather than creating a new Thread for each pan/zoom
new Thread(new Runnable()
{
@Override
public void run()
{
// Calculate Mandelbrot shape in the current viewing area
image.getRaster().setDataElements(0, 0, image.getWidth(), image.getHeight(),
man.generatePoints(canvas.tlClip, canvas.brClip));
canvas.repaint();
}
}).start();
}
private final class Canvas extends JPanel
{
final Point2D.Double tl = new Point2D.Double(0, 0);
final Point2D.Double br = new Point2D.Double(SIZE, SIZE);
// Point in Cartesian space at the top left of the viewing window
volatile Point2D.Double tlClip = new Point2D.Double(0, 0);
// Point in Cartesian space at the bottom right of the viewing window
volatile Point2D.Double brClip = new Point2D.Double(SIZE, SIZE);
AffineTransform transform = new AffineTransform();
Ellipse2D.Double backgroundCircle = new Ellipse2D.Double(0, 0, SIZE, SIZE);
int prevX, prevY;
double scale = 1;
Canvas(Mandelbrot man)
{
setPreferredSize(new Dimension(SIZE, SIZE));
addMouseListener(new MouseAdapter()
{
@Override
public void mouseClicked(MouseEvent e)
{
// For debugging
System.out.println("tlClip: " + tlClip);
System.out.println("brClip: " + brClip);
}
});
addMouseMotionListener(new MouseAdapter()
{
@Override
public void mouseMoved(MouseEvent e)
{
prevX = e.getX();
prevY = e.getY();
}
@Override
public void mouseDragged(MouseEvent e)
{
pan(man, e);
}
});
addMouseWheelListener(new MouseAdapter()
{
@Override
public void mouseWheelMoved(MouseWheelEvent e)
{
zoom(man, e);
}
});
}
private void pan(Mandelbrot man, MouseEvent e)
{
man.abortProcessing(); // Stop processing the previous request--we have a new one
int x = e.getX();
int y = e.getY();
double translateX = (x - prevX)/scale;
double translateY = (y - prevY)/scale;
transform.translate(translateX, translateY);
updateClip();
refresh(man);
prevX = x;
prevY = y;
}
private void zoom(Mandelbrot man, MouseWheelEvent e)
{
man.abortProcessing(); // Stop processing the previous request--we have a new one
int rotation = e.getWheelRotation();
Point2D p1 = e.getPoint();
Point2D p2 = null;
try
{
p2 = transform.inverseTransform(p1, null);
}
catch(NoninvertibleTransformException ex)
{
// Should not happen
ex.printStackTrace();
return;
}
transform.setToIdentity();
scale = (rotation < 0) ? scale * ZOOM_FACTOR : scale / ZOOM_FACTOR;
transform.translate(p1.getX(), p1.getY());
transform.scale(scale, scale);
transform.translate(-p2.getX(), -p2.getY());
updateClip();
refresh(man);
}
private void updateClip()
{
try
{
AffineTransform inv = transform.createInverse();
inv.transform(tl, tlClip);
inv.transform(br, brClip);
}
catch(NoninvertibleTransformException nte)
{
nte.printStackTrace();
}
}
private void reset()
{
transform.setToIdentity();
scale = 1;
repaint();
}
@Override
public void paint(Graphics g)
{
// final long startTime = System.nanoTime();
super.paint(g);
Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_SPEED);
g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING,
RenderingHints.VALUE_COLOR_RENDER_SPEED);
g2d.setColor(Color.GRAY);
g2d.fill(transform.createTransformedShape(backgroundCircle));
g2d.drawImage(image, 0, 0, null);
// long elapsedTime = System.nanoTime() - startTime;
// System.out.printf("painting time: %.3fs\n\n", elapsedTime / 1000000000.0);
}
}
}
ComplexNumber.java:
package mandelbrot;
public class ComplexNumber
{
public double real, complex;
public ComplexNumber(double real, double complex)
{
this.real = real;
this.complex = complex;
}
public ComplexNumber(double real)
{
this.real = real;
complex = 0;
}
public void add(ComplexNumber cn1, ComplexNumber cn2)
{
real = cn1.real + cn2.real;
complex = cn1.complex + cn2.complex;
}
public ComplexNumber square()
{
// double f = real * real;
// double o = real * complex;
// double i = real * complex;
// double l = -(complex * complex);
double realTemp = real;
real = real * real - complex * complex;
complex = realTemp * complex * 2;
return this;
}
@Override
public String toString()
{
return real + " + " + complex + "i";
}
}
void generatePoints
method, you are repeatedly calculatingy*y
inside the inner loop. Try precalculating it before the loop. \$\endgroup\$