4
\$\begingroup\$

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";
    }
}
\$\endgroup\$
1
  • 1
    \$\begingroup\$ In the void generatePoints method, you are repeatedly calculating y*y inside the inner loop. Try precalculating it before the loop. \$\endgroup\$
    – Rick Davin
    Commented Jun 14, 2019 at 20:11

1 Answer 1

2
\$\begingroup\$

This is a great project to do. I did a similar project in Swing; except using Clojure's Seesaw wrapper library. You can add many interesting add-on panels to it. My favorite panels that I came up with were a panel that lets the user decide how they want things colored, and one that allowed multiple images to be saved to disk in parallel. It was a great lesson in handling complex async processes. They're worth considering trying.

Anyways, onto some suggestions:

diverges can be made much more interesting if you allow it to return the i value that it fails at. This allows you to color your pixels based on the returned value of i, which adds a whole new dimension to what you can produce. This is my favorite image that I've ever produced (linking externally because it's 80 MB large; it's just my Google Drive. It's safe I swear!). Here's a tiny sample of it:

Colored Mandelbrot Sample

I'd change it to something like:

private int divergesAt(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) { // Don't neglect braces!
            return i;
        }
    }

    return beta;
}

Then when you want to decide what color to make that pixel, do something like:

int iters = divergesAt(x, y);

// java.awt.Color
Color pixelColor = new Color(wrap(x * iters), wrap(y * iters), wrap((x + y) * iters);

Where wrap is some function that wraps the value to ensure that it's always between 0 and 255.

You can create much more complicated "equations" as well though. Here are some example equations that I used (in Clojure; although you should be able to figure out what's going on). wr is a wrap shortcut, co is a Color constructor shortcut, x and y are the pixel coordinates, and n is the number of iterations that pixel failed at. Play around with it. I spent days just trying out different coloring methods.


On top of that, I have a couple comments regarding your diverages function:

  • I would use far more whitespace, like how I showed in my example. Cramming everything together doesn't lend itself to readability.

  • It's very confusing to have ComplexNumber as a mutable class. It's a number. Numbers are immutable. Ideally, the class methods should all return new ComplexNumbers instead of mutating themselves. This has the potential to be expensive; especially in a language like Java that doesn't lend itself to dealing with immutability. It will make your code make more sense though, and prevent bugs from popping up caused by background mutation.

  • Don't be tempted to neglect braces just because they aren't necessary! Many odd bugs have occurred because we told ourselves "I'll definitely remember to add braces if I refactor this later". Limit your ability to cause future problems for yourself.


On your question about precision, the only way I found around that was to use BigDecimal; but this creates massive performance problems. I found using doubles actually lets you zoom in pretty far before stuff starts getting weird. I actually found my iteration limits (like your beta here) to be larger deciding factors in how the final image looks. Anything less than 200 for the limit causes noticeable missed details to start popping up.

\$\endgroup\$
3
  • \$\begingroup\$ Great feedback; thank you. I'll have a look at the links you provided. Will upvote as soon as I have >=15 rep! \$\endgroup\$ Commented Jun 14, 2019 at 20:44
  • \$\begingroup\$ I'm still hoping for a satisfying solution to the precision problem... I wonder how the deep fractal zooms on Youtube were generated. \$\endgroup\$ Commented Jun 14, 2019 at 20:46
  • 2
    \$\begingroup\$ @PullNointer Likely using an arbitrary precision class like BigDecimal like I mentioned. They likely pre-computed everything before recording the zoom, so performance wasn't an issue. Or at least I always assumed that's how it was done. \$\endgroup\$ Commented Jun 14, 2019 at 20:48

Not the answer you're looking for? Browse other questions tagged or ask your own question.