1
\$\begingroup\$

I have created an interactive rendering of the Mandelbrot set in Processing. The image allows zooming (left and right mouse buttons) and translating (arrow keys) and prevents loss of quality by redrawing the set every time a change is made. It uses the standard color scheme from Ultra Fractal. I am not incredibly experienced in Java, so there might be programming patterns in the code that work better in C++ or C# than in Java, as that's what I'm used to.

Mandelbrot Set:

The Mandelbrot set is the set of complex numbers \$c\$ for which the function \$f_{c}(z)=z^{2}+c\$ does not diverge when iterated from \$z=0\$, i.e., for which the sequence \$f_{c}(0)\$, \$f_{c}(f_{c}(0))\$, etc., remains bounded in absolute value.

Source: https://en.wikipedia.org/wiki/Mandelbrot_set

Code:

//Mandelbrot set display (algorithm from https://en.wikipedia.org/wiki/Mandelbrot_set)
//Displays the Mandelbrot set while allowing zooming (Left mouse button -> zoom in, right mouse button -> zoom out)
//and translating (arrow keys) without loss of quality with live rendering

//Allows use of math functions on doubles
import java.lang.*;
//Determines how exact the outline of the Mandelbrot is
double iter = 200;

//Range of the visible image on the complex number plane (x, y)
double minX = -2;
double maxX = 0.75;
double minY = -1.375;
double maxY = 1.375;

//The point coordinates (0-1) to be zoomed in on
//(relative to screen)
double xZoom;
double yZoom;

//How far to zoom in onto point
//i.e. 0.1 == 10% distance reduction from sides
double zoomFact = 0.1;
//How far to offset image when translating
double offset = 0.1;

//Coordinates of the zooming point on the complex plane
double xPoint;
double yPoint;

//Distance from sides to point (complex plane)
double minXDist;
double maxXDist;
double minYDist;
double maxYDist;

//Is this the first render?
boolean first = true;
//Has there been input == is a new render necessary?
boolean update = false;
//true == zoom, false == translate
boolean scale = false;
//true == zoom in, false == zoom out
boolean zDir = true;

//Color palette for coloring based on iteration
color[] palette = {
color(66, 30, 15),
color(25, 37, 26),
color(9, 1, 47),
color(4, 4, 73),
color(0, 7, 100),
color(12, 44, 138),
color(24, 82, 177),
color(57, 125, 209),
color(134, 181, 229),
color(211, 236, 248),
color(241, 233, 191),
color(248, 201, 95),
color(255, 170, 0),
color(204, 128, 0),
color(153, 87, 0),
color(106, 52, 3),
};

//Called at start
void setup()
{
  size(1920, 1080);
  //Background is black only until first render ( ~100ms)
  background(0);
  //Scale sides to screen size to prevent distortion.
  minX = (minX / 1000) * width;
  maxX = (maxX / 1000) * width;
  minY = (minY / 1000) * height;
  maxY = (maxY / 1000) * height;
}

void draw()
{
  //Boolean check allows for zooming while holding mouse button
  if(mousePressed)
    mousePressed();

  //Render only when actually necessary
  if(update || first)
  {
    loadPixels();
    //Get and set the color for every pixel by checking its iteration count
      for(int y = 0; y < height; y++)
        for(int x = 0; x < width; x++)
          pixels[y * width + x] = mandel(x, y, iter);
    updatePixels();
    //Only one change "per input" (holding keys allowed)
    update = false;
  }
}

//Returns a color based on the pixels location on the complex plane
//and whether it belongs to the Mandelbrot set
color mandel(double x, double y, double maxIters)
{
  //Map the pixel to its value on the complex plane
  double xScale = mapD(x, 0, width, minX, maxX);
  double yScale = mapD(y, 0, height, minY, maxY);
  //Simulation of a complex number z
  double zReal = 0;
  double zImag = 0;
  double iteration = 0;
  //If r² + i² < 2^8 and max number of iterations reached -> part of the set
  while((zReal * zReal) + (zImag * zImag) < (1 << 16) && iteration < maxIters)
  {
    //Simulates z² + (x + yi)
    double zRTemp = (zReal * zReal) - (zImag * zImag) + xScale;
    zImag = (2 * zReal * zImag) + yScale; 
    zReal = zRTemp;
    iteration++;
  }
  //If we escaped before maxIters -> not part of the set, interpolate color
  if(iteration < maxIters)
  {
    double logZN = java.lang.Math.log(zReal * zReal + zImag * zImag) / 2;
    double nU = java.lang.Math.log(logZN / java.lang.Math.log(2)) / java.lang.Math.log(2);
    iteration = ++iteration - nU;
  }
  //In the set -> black
  if(iteration == maxIters)
    return color(0,0,0);
  //Lerp colors to get rid of "bands"
  color color1 = palette[((int) java.lang.Math.floor(iteration)) % 16];
  color color2 = palette[(((int) java.lang.Math.floor(iteration)) + 1) % 16];
  return lerpColor(color1, color2, ((float) iteration) % 1);
}

//Mouse -> Zoom in or out
void mousePressed()
{
  //Point to zoom in on = mouse position
  xZoom = ((float) mouseX) / ((float) width);
  xPoint = (maxX - minX) * xZoom + minX;
  yZoom = ((float) mouseY) / ((float) height);
  yPoint = (maxY - minY) * yZoom + minY;

  //Recalculate distances (new point)
  minXDist = xPoint - minX;
  maxXDist = maxX - xPoint;
  minYDist = yPoint - minY;
  maxYDist = maxY - yPoint;

  //Zoom in
  if(mouseButton == LEFT)
    zDir = true;
  //Zoom out
  else if(mouseButton == RIGHT)
    zDir = false;

  //Redraw, new scaling
  update = true;
  scale = true;

  updateValues();
}

//Key -> translate up, right, down, or left by offset
void keyPressed()
{
  switch(keyCode)
  {
    case UP:
      minY -= offset;
      maxY -= offset;
      break;
    case RIGHT:
      minX += offset;
      maxX += offset;
      break;
    case DOWN:
      minY += offset;
      maxY += offset;
      break;
    case LEFT:
      minX -= offset;
      maxX -= offset;
      break;
  }
  //Redraw, no scaling
  update = true;
  scale = false;
  updateValues();
}

//Updates values necessary for rendering
void updateValues()
{
  //Scale -> more values to update
  if(scale)
  {
    //Zoom in vs. zoom out
    if(zDir)
    {
      minX += minXDist * zoomFact;
      maxX -= maxXDist * zoomFact;
      minY += minYDist * zoomFact;
      maxY -= maxYDist * zoomFact;

      minXDist = xPoint - minX;
      maxXDist = maxX - xPoint;
      minYDist = yPoint - minY;
      maxYDist = maxY - yPoint;
    } else
    {
      minX -= minXDist * zoomFact;
      maxX += maxXDist * zoomFact;
      minY -= minYDist * zoomFact;
      maxY += maxYDist * zoomFact;

      minXDist = xPoint - minX;
      maxXDist = maxX - xPoint;
      minYDist = yPoint - minY;
      maxYDist = maxY - yPoint;
    }
  }
  //Offset based on range
  offset = (maxX - minX) * 0.005;
}

//Mapping function for doubles (standard only for floats)
static double mapD(double val, double oMin, double oMax, double nMin, double nMax)
{
  return ((val - oMin) / (oMax - oMin)) * (nMax - nMin) + nMin;
}

Thanks all!

\$\endgroup\$
1
\$\begingroup\$

If the range of the visible image is always centered around zero (as indicated by your minX, minY etc. values), you might benefit from representing the coordinate axes as a rational number type instead of double.

This gives you a greater range of non rounded numbers in that intervall which might lead to less round off errors when zooming, see https://stackoverflow.com/questions/5442640/is-there-a-commonly-used-rational-numbers-library-in-java

\$\endgroup\$
  • \$\begingroup\$ But the range doesn't remain centered on zero, in fact, it will almost never be \$\endgroup\$ – Light Drake May 21 '17 at 20:12
  • \$\begingroup\$ Hit post too early. I use the double type because it allows for high precision and thus lots of zooming before I lose quality. \$\endgroup\$ – Light Drake May 21 '17 at 20:13
1
\$\begingroup\$

Nice code.

Some points to consider:

If I understand the code correctly, the void draw() function will be looped and redraw the image, if the update boolean evaluates to true. In order to check for this effortlessly, you have declared update a global variable. You should only declare as many global variables as needed to avoid name cluttering (especially with such a common name such as update). But your code itself doesn't really benefit from the global usage of update. Instead of writing

 update = true;
 scale = false;
 updateValues();

You could pass the respective truth values to the function like

updateValues(true,false);

You should do the same with

void draw()

so that this function is only called from the other functions when it is needed, e.g.

void draw(true)
\$\endgroup\$
  • \$\begingroup\$ Indeed, scale could be passed to updateValues(), but you can't pass arguments to draw() in Processing because you shouldn't call it yourself (Processing calls it automatically every frame). That's why I used a spherical variable for that. \$\endgroup\$ – Light Drake May 22 '17 at 10:34
1
\$\begingroup\$
//Allows use of math functions on doubles
import java.lang.*;

I know nothing about Processing, but this looked suspicious to me, so I went looking for the maths functions in question. I presume it's e.g.

double logZN = java.lang.Math.log(zReal * zReal + zImag * zImag) / 2;

In Java the point of import java.lang.* (if it were necessary, which it isn't, because that's imported implicitly) would be to allow you to write Math.log instead of java.lang.Math.log. Maybe that's a difference between Java and Processing, and this is definitely a case of the blind leading the blind, but if you're not certain that you've got it right then check whether you need the namespace on the actual invocations.


//Is this the first render?
boolean first = true;
//Has there been input == is a new render necessary?
boolean update = false;
...
  //Render only when actually necessary
  if(update || first)
  {
    ...
    update = false;
  }

Two things: firstly, there's a bug, because first will always be true; secondly, it seems to me that first is entirely unnecessary and could be replaced by simply initialising

//Has there been input == is a new render necessary?
boolean update = true;

  //Scale sides to screen size to prevent distortion.
  minX = (minX / 1000) * width;
  maxX = (maxX / 1000) * width;
  minY = (minY / 1000) * height;
  maxY = (maxY / 1000) * height;

It's not entirely clear how this prevents distortion. From the context and your comment I understand that the point is to adjust the aspect ratio so that pixels are assumed to be square, but the comment doesn't really explain itself, and it doesn't do anything to prevent you changing the initial values of minX etc. in such a way that this rescaling introduces distortion.

I think it would be more maintainable to have the initial values in the form of x and y coordinates for the centre and then a scale parameter. If the code were

  minX = initialCentreX - initialScale * width / 2;
  maxX = initialCentreX + initialScale * width / 2;
  minY = initialCentreY - initialScale * height / 2;
  maxY = initialCentreY + initialScale * height / 2;

then I think it would be more self-documenting, and you could change initialCentreX etc. without worrying about breaking it.


  if(mousePressed)
    mousePressed();

This will be highly debatable, but I find that naming unhelpful. I'd prefer something like if (mousePressed) handleMousePress();


    loadPixels();
    //Get and set the color for every pixel by checking its iteration count
      for(int y = 0; y < height; y++)
        for(int x = 0; x < width; x++)
          pixels[y * width + x] = mandel(x, y, iter);
    updatePixels();

Is that loadPixels() necessary? It might be (again, blind leading the blind), but the name suggests that's it's more intended for cases where you want to modify some of the current buffer without modifying it all. Given that you're updating every pixel, it may be unnecessary overhead.

Something funny seems to have happened to the indentation here.

This is a micro-optimisation which may be premature, but since the point of iterating over y first is to get good cache locality, I would take advantage and remove the multiplication:

      off = 0
      for(int y = 0; y < height; y++)
        for(int x = 0; x < width; x++)
          pixels[off++] = mandel(x, y, iter);

mapD seems a bit more general than you really need. Since oMin is always 0 it could be simplified. Alternatively, you could use it to simplify the calculations at the start of the mouse handler.


  color color1 = palette[((int) java.lang.Math.floor(iteration)) % 16];

Assuming Processing doesn't diverge too far from Java, (int)iteration is equivalent to (int)java.lang.Math.floor(iteration), and I find it more readable.

\$\endgroup\$
  • \$\begingroup\$ 1. I'm not on my PC right now, so I'll check that later. 2. You're right, first is an artifact from before I refactored the code, forgot to remove it. 3. Could you elaborate on that? I'm not sure what you mean. The current scaling was made so I could scale it from my original square view to a full screen one. 4. mousePressed and mousePressed() are functions supplied by Processing, mousePressed is active whenever the mouse is being held, mousePressed() only triggers once. It's a little hackisch to do it this way. loadPixels() must be called before modifying the pixel array. \$\endgroup\$ – Light Drake May 22 '17 at 14:37
  • \$\begingroup\$ 5. The offset is good idea, thanks. 6. mapD is also a leftover from my attempt to utilize an arbitrary precision library (which failed in performance fantastically). Could probably change that. 7. I didn't check whether casting to int floors or rounds in Processing, went with the safe option at the time, would likely be worth changing as well. Thanks! \$\endgroup\$ – Light Drake May 22 '17 at 14:41
  • \$\begingroup\$ @LightDrake, I've elaborated point 3. Hope it makes more sense now. \$\endgroup\$ – Peter Taylor May 22 '17 at 14:59
  • \$\begingroup\$ Thanks, I'll try that later (along with the other stuff). Once I've done that, I'll accept your answer :) \$\endgroup\$ – Light Drake May 22 '17 at 15:01
  • \$\begingroup\$ 1. Correct. 2. Changed it. 3. The scaling you proposed breaks the program, I implemented a system based on an initialCenterX and initialCenterY as well as an initalScale too, but it uses the formula initialCenter{X/Y} {+/-} ({width/height} / 2) / initialScale instead. 4. Renamed mousePressed() to Zoom() to clarify. 5. Implemented. 6. Didn't catch that at all, used mapD() in Zoom() as well now. 7. Casting to int floors. If you could change the formula for scaling up top, I'll accept the answer :) \$\endgroup\$ – Light Drake May 22 '17 at 17:04

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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