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 r2 + i2 < 2^8 and max number of iterations reached -> part of the set
while((zReal * zReal) + (zImag * zImag) < (1 << 16) && iteration < maxIters)
{
//Simulates z2 + (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!
3 Answers 3
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
-
\$\begingroup\$ But the range doesn't remain centered on zero, in fact, it will almost never be \$\endgroup\$Light Drake– Light Drake2017年05月21日 20:12:45 +00:00Commented May 21, 2017 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– Light Drake2017年05月21日 20:13:47 +00:00Commented May 21, 2017 at 20:13
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)
-
\$\begingroup\$ Indeed, scale could be passed to
updateValues()
, but you can't pass arguments todraw()
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– Light Drake2017年05月22日 10:34:06 +00:00Commented May 22, 2017 at 10:34
//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.
-
\$\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– Light Drake2017年05月22日 14:37:53 +00:00Commented May 22, 2017 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– Light Drake2017年05月22日 14:41:28 +00:00Commented May 22, 2017 at 14:41
-
\$\begingroup\$ @LightDrake, I've elaborated point 3. Hope it makes more sense now. \$\endgroup\$Peter Taylor– Peter Taylor2017年05月22日 14:59:48 +00:00Commented May 22, 2017 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– Light Drake2017年05月22日 15:01:51 +00:00Commented May 22, 2017 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
andinitialCenterY
as well as aninitalScale
too, but it uses the formulainitialCenter{X/Y} {+/-} ({width/height} / 2) / initialScale
instead. 4. RenamedmousePressed()
toZoom()
to clarify. 5. Implemented. 6. Didn't catch that at all, usedmapD()
inZoom()
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– Light Drake2017年05月22日 17:04:05 +00:00Commented May 22, 2017 at 17:04