In yesterday’s article I wrote a simple 2D graphics library that provided lines and quadrangles as primitives. My motivation for writing it was so I could then write what I really wanted to do, which was some 3D drawing. In particular I wanted to draw a 3D surface where a function is provided that returns a Z (height) value for supplied X and Y values. I wanted to draw a ripple and view in 3D.
The first part was to create a framework that could call the function for determining Z value. I used the following prototype for my plot function.
typedef double (*PlotFunction3D) ( double X, double Y );
This takes X and Y values between 0 and 1 and returns a Z value between 0 and 1. For my ripple function I wrote the following:
double RippleFunction ( double x, double y ) { double pi = 3.14159265358979; double z; double dist; double wave; // Get distance of point from centre (0.5, 0.5) dist = sqrt( (0.5-x)*(0.5-x) + (0.5-y)*(0.5-y) ); wave = sin( 4*(2*pi) * dist ) * 0.4; z = (wave / 2.0) + 0.5; if( dist > 0.5 ) { z = 0.5; } return z; }
This calculates the Z (height) value using a sine wave on the distance from the centre of the range (0.5, 0.5). The part at the end prevents the ripple continuing to the full edge and constrains it to a circle. This function is all that defines the surface, the rest of the program is generic and can draw any surface just by providing a different plot function. It is a lot of fun to experiment with different functions and see what results you can get.
By calling this function at various points over the X and Y range, a height value for a surface is found. This now needs to be displayed. To start with I wanted to draw a wire frame. By taking X and Y points at even intervals across the range 0 – 1, I calculated a matrix of Z values. To draw as a wire frame these Z values (Along with their X and Y) are used as vertices between line segments. A line segment is drawn between each set of adjacent points. However first their 3 dimensional coordinates must be converted into a 2D set for the screen.
I used a very simple method for converting the 3D points to 2D. The following code takes X,Y,Z values between 0 and 1 and returns 2D coordinates in the integer range representing pixels in the window.
SgPoint GetPointFrom3D ( double x, double y, double z ) { SgPoint point; double X; double Y; X = (x*0.8) + (y*0.4); Y = (y*0.5) + (z*0.6); point.X = (uint16_t) (X * (double)WINDOW_SIZE_X * 0.8); point.Y = WINDOW_SIZE_Y - (uint16_t) (Y * (double)WINDOW_SIZE_Y * 0.8); return point; }
In my example I had a window size of 600 x 600 pixels, So the X,Y coordinate returned (SgPoint is a struct containing an x and y integer value) was in the range 0 – 599.
This is a crude 3D conversion, as it does not take perspective into consideration, nor does it allow you to change the viewing angle. This function can be replaced with a better function that would allow for these and the rest of the program could stay the same.
So now I have my matrix of vertices converted to 2D space, it is just a matter of drawing the lines
#define MESH_GRID_POINTS 50 typedef struct { double mesh [MESH_GRID_POINTS+1][MESH_GRID_POINTS+1]; } MeshPoints; void DrawPlotFunction3DWireMesh ( PlotFunction3D Function ) { MeshPoints* meshXCoord; MeshPoints* meshYCoord; MeshPoints* meshZCoord; int ix; int iy; SgPoint prevPoint; SgPoint point; meshXCoord = malloc( sizeof(MeshPoints) ); meshYCoord = malloc( sizeof(MeshPoints) ); meshZCoord = malloc( sizeof(MeshPoints) ); // Calculate mesh coordinates CalculateMeshCoordinates( Function, meshXCoord, meshYCoord, meshZCoord ); // Draw mesh lines along X for( ix=0; ix<=MESH_GRID_POINTS; ix+=1 ) { for( iy=1; iy<=MESH_GRID_POINTS; iy+=1 ) { // Draw line segment from previous point to this one prevPoint = GetPointFrom3D( meshXCoord->mesh[ix][iy-1], meshYCoord->mesh[ix][iy-1], meshZCoord->mesh[ix][iy-1] ); point = GetPointFrom3D( meshXCoord->mesh[ix][iy], meshYCoord->mesh[ix][iy], meshZCoord->mesh[ix][iy] ); SgDrawLine( prevPoint, point, SgRGB(0,0,0) ); } } // Draw mesh lines along Y for( iy=0; iy<=MESH_GRID_POINTS; iy+=1 ) { for( ix=1; ix<=MESH_GRID_POINTS; ix+=1 ) { // Draw line segment from previous point to this one prevPoint = GetPointFrom3D( meshXCoord->mesh[ix-1][iy], meshYCoord->mesh[ix-1][iy], meshZCoord->mesh[ix-1][iy] ); point = GetPointFrom3D( meshXCoord->mesh[ix][iy], meshYCoord->mesh[ix][iy], meshZCoord->mesh[ix][iy] ); SgDrawLine( prevPoint, point, SgRGB(0,0,0) ); } } free( meshXCoord ); free( meshYCoord ); free( meshZCoord ); }
And now the result:
This looks interesting, but rather messy because its a wireframe you can see through it. I wanted to have something that looked like a surface rather than something made of chicken wire. Instead of drawing lines between the vertices I drew quadrangles between sets of 4 vertices. The quadrangles were drawn as filled white quadrangles with black edges. By drawing the quadrangles starting from the back row and working forward, the front quadrangles overwrite the ones behind. By putting a delay in the drawing loop you could watch the surface being drawing which looked rather interesting.
I changed the above routine to use the following to draw the quadrangles
// Draw quadrangles between sets of 4 points for( iy=MESH_GRID_POINTS; iy>1; iy-=1 ) { for( ix=1; ix<=MESH_GRID_POINTS; ix+=1 ) { points[0] = GetPointFrom3D( meshXCoord->mesh[ix-1][iy-1], meshYCoord->mesh[ix-1][iy-1], meshZCoord->mesh[ix-1][iy-1] ); points[1] = GetPointFrom3D( meshXCoord->mesh[ix][iy-1], meshYCoord->mesh[ix][iy-1], meshZCoord->mesh[ix][iy-1] ); points[2] = GetPointFrom3D( meshXCoord->mesh[ix][iy], meshYCoord->mesh[ix][iy], meshZCoord->mesh[ix][iy] ); points[3] = GetPointFrom3D( meshXCoord->mesh[ix-1][iy], meshYCoord->mesh[ix-1][iy], meshZCoord->mesh[ix-1][iy] ); SgFillQuadrangle( points[0], points[1], points[2], points[3], SgRGB(255,255,255) ); SgDrawQuadrangle( points[0], points[1], points[2], points[3], SgRGB(0,0,0) ); } }
The result looks much better than before:
By having smaller quadrangles a smoother effect is given, but if they are too small then the the lines get drawn too close or on top of each other and you can’t make out a shape. By getting rid of the edges and instead using shading it is possible to use much smaller quadrangles, as long as the shading makes them different colours, otherwise it will be a solid colour and no shape will be seen.
Ideally the shading would be based on some lighting effect using the angle of the quadrangles to the viewer. However I found I got quite reasonable effect just by using the Z value to determine the shading colour for each quadrangle.
By changing the SgFillQuadrant line with
colour = SgRGB(50+(double)meshZCoord->mesh[ix][iy]*255,0,0); SgFillQuadrangle( points[0], points[1], points[2], points[3], colour );
and removing the SgDrawQuadrangle the surface could be quite nicely rendered using a much smaller quadrangles. I increased the number from 50 per row to 500.
The now for the final result:
Finally, if you would like the source code to play around with it is available here
3dSurface.zip – Contains source code for the above examples along with LibSimpleGraphics (0.1.0 non stable version). As always this is free and unencumbered software released into the public domain.
I am planning on finishing the LibSimpleGraphics library sometime soon, at which point I’ll put it on GitHub.