Part III: The FractalView Window
This is a bit more complex, as there are a few issues to handle. The FractalView window manages the specific generator instance, handles user input, elaborates and displays output and performs a few other tasks.
It is a window that contains three controls: a top menu, a bottom status bar and a PictureBox set to Dock = Fill to fill the whole free window area. The following code shows the contructor of the window:
public
FractalView(string title, IGenerator generator, Options options)
{
this.options
= options;
this.generator
= generator;
this.traceSel
= false;
this.selecting
= false;
this.history
= new
List<Image>(options.historySize);
this.currentImage
= -1;
InitializeComponent();
Text = title;
this.ClientSize
= new Size(options.xRes,
options.yRes + this.statusBar.Height
+ this.tsMenu.Height);
}
It
is quite clear what that code does. The traceSel and selecting
members are used by the area selection routine. The history member is
initialized with the user specified size.
When
the Fractal is generated for the first time, or when the user selects
a plane area and wants to zoom into it, a domain must be computed so
that the generator knows what portion of a potentially infinite plane
should be considered.
The
default value is algorithm dependant and is used for the first image.
After that, the new domain is computed as follows:
private
Domain ComputeDomain()
{
Domain
domain = this.generator.GetDomain();
Domain
newDomain = new
Domain();
newDomain.X
= domain.X + (domain.Width / this.options.xRes)
* this.zoomArea.X;
newDomain.Y
= domain.Y - (domain.Height / this.options.yRes)
* this.zoomArea.Y;
newDomain.Width
= domain.Width / (this.options.xRes
/ Math.Max(this.zoomArea.Width,
1));
newDomain.Height
= newDomain.Width * (domain.Height / domain.Width); //Keep
aspect ratio
return
newDomain;
}
The
Domain structure is a simplified version of the RectangleF structure
available in C#, the main difference being that Domain uses doubles
instead than floats.
The
zoomArea variable is nothing but the currently selected area of the
image. For details about how I handle the selection please look at
the code.
DrawFractal() method
The most important method is DrawFractal(). It deserves special attention and so it will be split in a few parts:
private
void DrawFractal(Domain domain)
{
DateTime
t1 = DateTime.Now;
this.generator.SetDomain(domain);
Cursor.Current = Cursors.WaitCursor;
//Split
work in tasks
RenderTask[]
tasks = new
RenderTask[this.options.nThreads];
Thread[]
threads = new
Thread[this.options.nThreads];
double
dy = domain.Height / this.options.nThreads;
int
dyres = this.options.yRes /
this.options.nThreads;
for
(int i = 0; i <
this.options.nThreads; ++i)
{
Domain
dt = new Domain();
dt.X
= domain.X;
dt.Y
= domain.Y - (i * dy);
dt.Width
= domain.Width;
dt.Height
= dy;
dyres = i < (this.options.nThreads - 1) ? dyres : this.options.yRes - dyres * i;
tasks[i]
= new
RenderTask(this.generator,
dt, this.options.xRes *
this.options.AA, dyres *
this.options.AA);
threads[i]
= new Thread(new
ThreadStart(tasks[i].DoWork));
threads[i].Start();
}
//Wait
for the rendering tasks
for(int
i = 0; i < this.options.nThreads;
++i)
threads[i].Join();
A
few years ago multiprocessor systems were so rare that building
single threaded application was not a shame. Today CPU features more
than one computational unit, and adding cores seems the main trick to
improve computers speed. This means that when you write a
computationally intensive algorithm you need to carefully consider
how to split the work among more threads. Avoiding that might become
a crime soon or later :-)
We
are lucky: many interesting CG algorithms (like raytracing) are easy
to multithread. As you can see from the code, I create as many
instances of the RenderTask class as defined by the nThreads variable
in the options member. RenderTask will be explained later. The
trivial way to split the work is to split the domain and execute the
Fractal routine on those partial domains at the same time.
After the threads are executed, the join() method is called on each of them so that the main thread waits for them to be completed.
Important architectural consideration: I could have decided to create several generator instances, one for each thread. This would have been a better idea perhaps, but the code would have been slightly more complex. As a consequence of this choice, every generator implementation must be thread safe. For example, the drawing routine should not write on member variables, but only on variables defined inside the method itself, unless it carefully handles syncronization and so on in order to avoid data corruption.
//Compose
the sections in one image
Bitmap
img = new
Bitmap(this.options.xRes
* this.options.AA,
this.options.yRes *
this.options.AA);
Graphics
gx = Graphics.FromImage(img);
for
(int i = 0; i <
this.options.nThreads; ++i)
{
gx.DrawImage(tasks[i].Output,
new Rectangle(0,
dyres * i * this.options.AA,
this.options.xRes *
this.options.AA, dyres *
this.options.AA));
tasks[i].Output.Dispose();
}
if(this.options.AA
> 1)
{
Bitmap
res = new
Bitmap(this.options.xRes,
this.options.yRes);
gx
= Graphics.FromImage(res);
gx.InterpolationMode
= System.Drawing.Drawing2D.InterpolationMode.High;
gx.DrawImage(img, new Rectangle(0, 0, this.options.xRes, this.options.yRes));
img.Dispose();
img =
res;
}
Once all threads have completed their tasks, the partial images can be found in RenderTask.Output. A new bitmap is created with the resolution of the full frame. If we compute the Fractal function only once for each pixel, the resulting image will be severely affected by ugly aliasing artifacts. Antialiasing itself is a huge topic: aliasing naturally appears as soon as we try to sample a function using too few samples. It does not matter if we are considering image functions (pictures, 3D scenes) or acoustic functions (a sound) or any other type.
In our case the function is the Fractal equation and the samples are the pixels. There is no need, for this tutorial, to make things unnecessarily complex, so I simply supersample the function, that is, increase the resolution by n times, where n is a user defined value. Once the image is ready, I reduce it using one of the filtering options available in .Net.
The last thing to do is gamma correction:
//Apply gamma correction
for(int
x = 0; x < img.Width; ++x)
for
(int y = 0; y <
img.Height; ++y)
{
Color
color = img.GetPixel(x, y);
double
r = (float)color.R / 255;
double
g = (float)color.G / 255;
double
b = (float)color.B / 255;
if
(r > 0.0031308) r = 1.055 * (Math.Pow(r,
(1 / 2.2f))) - 0.055;
else
r = 12.92 * r;
if
(g > 0.0031308) g = 1.055 * (Math.Pow(g,
(1 / 2.2f))) - 0.055;
else
g = 12.92 * g;
if
(b > 0.0031308) b = 1.055 * (Math.Pow(b,
(1 / 2.2f))) - 0.055;
else
b = 12.92 * b;
img.SetPixel(x,
y, Color.FromArgb((int)(r
* 255), (int)(g * 255),
(int)(b * 255)));
}
DateTime t2 = DateTime.Now;
this.tsslTime.Text
= string.Format("Rendering:
{0} s", t2.Subtract(t1).TotalSeconds);
this.tsslDomain.Text
= string.Format("Domain:
{0}",
this.generator.GetDomain().ToString());
if
(this.history.Count >=
this.history.Capacity)
this.history.RemoveAt(0);
this.history.Add(img);
this.currentImage
= this.history.Count - 1;
this.tsslIndex.Text
= string.Format("Image
{0}/{1}", this.currentImage
+ 1, this.history.Count);
this.pbxImage.Image
= img;
this.traceSel
= false;
Cursor.Current
= Cursors.Default;
}
For
those who never heard about that (or did and always wondered what it
was) I provide here a short explanation. It is a useful thing to know
for anyone, and expecially important for graphic programmers and
digital artists.
Back
in the days when we still were using CRT displays, the intensity
value of a screen pixel was related to the voltage applied to the
circuits. The voltage was modulated by the value of that pixel in the
image data. If the image pixel was two times bigger than another, the
voltage applied to that screen pixel was two times bigger as well.
It happened though that the brightness of the screen pixel was not linear with the voltage applied, and this meant that a pixel that was supposed to be two times brighter than another actually was darker than what expected. For this reason they decided to change the pixel values with a non-linear function so that the resulting screen intensities were correct. This process got the name of Gamma Correction.
Old games such as Quake featured the option to change the parameters of that function because different displays could have different responses. Todays LCD technology does not suffer from the same problem, but vendors decided to introduce that behaviour anyway to keep compatibility with older data and software. A standard color space named sRGB has been formulated, and it is the one used for the Web. The code shown performs that transformation on the Fractal output.
Remember that every time a synthetic image is generated in linear space, it must be gamma corrected in order to look correct once displayed. Usually images stored on disk are already gamma corrected. A notably exception are High Dynamic Range images (hdr, exr) which are in linear space.
The rest of the code simply prints informations about the current image: time required to be generated, domain and index.
The RenderTask class
As anticipated, here is the RenderTask code:
class
RenderTask
{
private
IGenerator
generator;
public
Domain
Domain { get;
set;
}
public
int
ResX {get;
set;}
public
int
ResY {get;
set;
}
public
Image
Output { get;
set;
}
protected
RenderTask()
{}
public
RenderTask(IGenerator
generator, Domain
d, int
rx, int
ry)
{
this.generator
= generator;
Domain =
d;
ResX =
rx;
ResY =
ry;
}
public
void
DoWork()
{
Output
= this.generator.Draw(Domain,
ResX, ResY);
}
}
Not much, really. When the thread starts it executes the specified method, DoWork(). It simply forwards the call to IGenerator.Draw() with the specified domain and resolution.
The FractalView form does a few more things. A button is provided to allow image saving. In addition, a list of generated images is kept. That list has a maximum size defined by the user. Two more buttons allow the user to switch through the buffered images.