Pushing Paint applications into Web 2.0 using Silverlight
Paint applications on the desktop has been developing for a long time however there are few available on the internet, even less that offer the same control that you would find on a desktop paint application, even one as simple as Paint. Let me show you how you can make your own online paint application in Silverlight.
One of the reasons this hasn’t been done before in Silverlight is the way it renders graphics and shapes. It uses Vectors; which are great if you want to create shapes that look good at any resolution or zoom level but not so good for us. Why? Well you can’t say what colour is at point [5, 5] for example if we load in an image and then draw a shape on top. Or what if we want to draw a pen line, well we could use the Ink tools but they are all separate and distinct and in the end we can’t bring them all together in a saveable image. Thus we need to create our own pixel based graphics library that incorporates the required functionality.
Luckily most of the hard work was figured out for us by Joe Stegman who incorporated a PNG encoder to link between the Silverlight Image control and a pixel level graphics array of bytes accessible via Get and Setpixel functions.
We need to increase the functionality of this, so I went through the GDI+ graphics library and copied most of the function signatures for the ones that would be useful (or easy to implement) and then added a few additional ones that would help the applications performance and scalability, below is the class diagram for the finished graphics library.
As you can see we have quite the selection of Methods, quite a few of them we don’t actually require for this project however I thought it would be useful if the graphics library was used elsewhere.
Now let’s talk about how the graphics library actually works and what goes on under the hood. The image data is stored in a byte array. Each screen pixel requires 4 bytes (representing A, R,G,B) and then a parity byte at the end of each row, thus for an image of size [w, h] the required bytes are (w * 4 + 1) * h. Set and Get Pixel process the coordinates and read / write from a position in the byte array.
Some modifications were made to the PNG encoding library, such as altering the way it copies arrays to use System.Array.Copy() to increase speed, as we will constantly be updating as fast as possible every millisecond counts!
One of the first functions we needed to create was a DrawLine() function; the default signature of which being DrawLine(Color color, int x1, int y1, int x2, int y2) this function would effectively walk the line in both horizontal and then vertical 1 pixel increments and plot the resulting pixel using Bresenham's algorithm, this gave a fast and effective algorithm however it lacks anti-aliasing though for this implementation that is acceptable. To improve performance we bypass SetPixel and set the bytes directly otherwise we would constantly have to recalculate the colour values. Once we have a line drawing function in place others come naturally, such as DrawRectangle() etc.
public void DrawLine(Color color, int x1, int y1, int x2, int y2)
{
byte red = color.R;
byte green = color.G;
byte blue = color.B;
byte alpha = color.A;
int start;
// Uses Bresenham's algorithm to draw a line
// Work out the start, end and step points
double startX = x1;
int endX = x2;
double yStep = (y2 - y1) / (endX - startX);
double yPos = y1;
if (endX < startX)
{
startX = x2;
endX = x1;
yStep = (y1 - y2) / (endX - startX);
yPos = y2;
}
// First we traverse the x plane
for (int x = (int)startX; x < endX; x++)
{
start = _rowLength * (int)yPos + x * 4 + 1;
if (x < Width && yPos < Height && x > 0 && yPos > 0)
{
// Set the buffer directly for increased speed
_buffer[start] = red;
_buffer[start + 1] = green;
_buffer[start + 2] = blue;
_buffer[start + 3] = alpha;
}
yPos += yStep;
}
// Secondly we traverse the y plane
double startY = y1;
int endY = y2;
double xStep = (x2 - x1) / (endY - startY);
double xPos = x1;
if (endY < startY)
{
startY = y2;
endY = y1;
xStep = (x1 - x2) / (endY - startY);
xPos = x2;
}
for (int y = (int)startY; y < endY; y++)
{
start = _rowLength * y + (int)xPos * 4 + 1;
if (xPos < Width && y < Height && xPos > 0 && y > 0)
{
// Set the buffer directly for increased speed
_buffer[start] = red;
_buffer[start + 1] = green;
_buffer[start + 2] = blue;
_buffer[start + 3] = alpha;
}
xPos += xStep;
}
}
Drawing an ellipse is slightly more complex, for this I used a modified version of the ellipse rendering code found on Wikipedia. It creates lines connecting 72 splines around the ellipse at the key positions; I choose 72 as an arbitrary number that provides a high level of detail.
private Point[] GetEllipsePoints(int x, int y, int width, int height)
{
// Iterate through 72 points
int steps = 72;
double angle = Math.PI / 4;
double centerX = (double)width / 2;
double centerY = (double)height / 2;
// Store for the keypoints around the Ellipse
List<Point> points = new List<Point>();
// Angle is given by Degree Value
double beta = -angle * (Math.PI / 180); //(Math.PI/180) converts Degree Value into Radians
double sinbeta = Math.Sin(beta);
double cosbeta = Math.Cos(beta);
for (double i = 0; i < 360; i += 360 / (double)steps)
{
double alpha = i * (Math.PI / 180);
double sinalpha = Math.Sin(alpha);
double cosalpha = Math.Cos(alpha);
double X = x + centerX + (centerX * cosalpha * cosbeta - centerY * sinalpha * sinbeta);
double Y = y + centerY + (centerX * cosalpha * sinbeta + centerY * sinalpha * cosbeta);
points.Add(new Point(X, Y));
}
return points.ToArray();
}
Bucket fill proved interesting, from a starting point it would iterate through till an area enclosed by the same original colour was filled. This was achieved by first creating a copy of the buffer store, though in the form of a 2D colour grid, and then using a queue to process each item. The first item in the queue is the original point pasted to the function. The queues iteration loop would check the four surrounding pixels to see if they are the same as the origin pixel colour and if so, would change its colour and add the position to the queue if the square hadn’t already been processed. Once the queue is empty the bucket fill is complete.
public void BucketFill(Color color, int x, int y)
{
// Store the colour of the origin pixel
Color OriginPixelColour = GetPixel(x, y);
// First to make things easier we need to create a backup of the graphic
Color[,] _backupGrid = CreateColourGrid();
bool[,] ProgressGrid = new bool[Width, Height];
// Create a queue of all the points we need to check through
Queue<Point> ProgressQueue = new Queue<Point>();
// Set the initial queue item to the origin
ProgressQueue.Enqueue(new Point(x, y));
ProgressGrid[x, y] = true;
// Iterate through the queue till there are no items
do
{
// Dequeue the item
Point QueueItem = ProgressQueue.Dequeue();
int X = (int)QueueItem.X;
int Y = (int)QueueItem.Y;
// Change the colour
SetPixel(X, Y, color);
// Now go through the different directions
if (Y >= 1 && !ProgressGrid[X, Y - 1] && _backupGrid[X, Y - 1] == OriginPixelColour)
{
ProgressGrid[X, Y - 1] = true;
ProgressQueue.Enqueue(new Point(X, Y - 1));
}
if (Y < Height - 1 && !ProgressGrid[X, Y + 1] && _backupGrid[X, Y + 1] == OriginPixelColour)
{
ProgressGrid[X, Y + 1] = true;
ProgressQueue.Enqueue(new Point(X, Y + 1));
}
if (X >= 1 && !ProgressGrid[X - 1, Y ] && _backupGrid[X - 1, Y] == OriginPixelColour)
{
ProgressGrid[X - 1, Y] = true;
ProgressQueue.Enqueue(new Point(X - 1, Y));
}
if (X < Width- 1 && !ProgressGrid[X + 1, Y] && _backupGrid[X + 1, Y] == OriginPixelColour)
{
ProgressGrid[X + 1, Y] = true;
ProgressQueue.Enqueue(new Point(X + 1, Y));
}
}
while (ProgressQueue.Count != 0);
}
Initialisation for the image was moved to the constructor method to improve perceived performance as there would not be a slight lag the first time the image is drawn to.
public Bitmap(int width, int height)
{
this.Width = width;
this.Height = height;
// Initalise everything
_rowLength = _width * 4 + 1;
_buffer = new byte[_rowLength * _height];
_init = true;
}
Other functions such as DrawImage() just transfer the bytes from a previously defined Bitmap to the current object, due to their simplicity I thought it would be best to carry on and not go into much detail about them.
So now we have a fully functional dynamically generated Bitmap class. Moving on we must now go ahead and implement the front end this plugs into. To simplify things the window will be split into several Grids, one to house the main paint window, status bar, tool bar and menu. For the moment we will concentrate on the main window and implement that.
In the main windows code we need to declare two bitmaps using the library we just created and then one using Silverlights graphics library.
public Bitmap(int width, int height)
{
this.Width = width;
this.Height = height;
// Initalise everything
_rowLength = _width * 4 + 1;
_buffer = new byte[_rowLength * _height];
_init = true;
}
_Frontbuffer is where the main drawing will take place before it is drawn to the screen; _Backbuffer is used to hold temporary graphics information for certain operations and brushes.
The image itself will consist of an Image control ‘DrawSurface’ surrounded by 3 Rectangles for the different shadows to give the image a 3d relief effect ‘DrawSurfaceShadowRight’, ‘DrawSurfaceShadowBottom’ and ‘DrawSurfaceShadowCorner’.
|
|
|
|
|
|
|
|
|
The shadow fill of the rectangles is created by a LinearGradientBrush going from Gray to LightSteelBlue (the paint windows background colour). The positions of the Shadows are based on the dimensions of the internal drawable bitmap _FrontBuffer although offset northeast by 5 pixels to give the effect the canvas is hovering. We package this up as a function that we call whenever the dimensions of _FrontBuffer changes.
private void SetupShadows()
{
// Set the dimensions and margins
DrawSurfaceShadowRight.Height = _Frontbuffer.Height - 5;
DrawSurfaceShadowRight.Margin = new Thickness(30 + _Frontbuffer.Width, 35, 0, 0);
DrawSurfaceShadowBottom.Width = _Frontbuffer.Width - 5;
DrawSurfaceShadowBottom.Margin = new Thickness(35, 30 + _Frontbuffer.Height, 0, 0);
DrawSurfaceShadowCorner.Margin = new Thickness(30 + _Frontbuffer.Width, 30 + _Frontbuffer.Height, 0, 0);
// Set up the gradient colours and offsets
GradientStopCollection _GSC = new GradientStopCollection() {
new GradientStop() { Color = Colors.Gray, Offset = 0 },
new GradientStop() { Color = Color.FromArgb(255, 176, 196, 222), Offset = 1 } };
// Set the right, bottom and corner gradients
DrawSurfaceShadowBottom.Fill = new LinearGradientBrush(_GSC, 90);
DrawSurfaceShadowRight.Fill = new LinearGradientBrush(_GSC, 0);
DrawSurfaceShadowCorner.Fill = new LinearGradientBrush(_GSC, 45);
}
Different tools in Silverpaint are used by storing the ID for the tool which is in the form of a string eg. Pen, Line, BucketFill etc. The type of tool is checked on various mouse events on the Drawable surface depending on how they effect it.
The source code for SilverPaint is available for download at SilverPaints page on MSDN.