Gerbyte : Simple 3D Graphics Primer

Right. You've come to this page because you're interested in taking your first step into creating 3D graphics.

Excellent choice!

This page will explain how to do just that.


==Background==

Back in the old days I studied 3D graphics at university. Not how to model 3D models, but at a deeper level, writing the code to project a 3D model onto a 2D plane and applying textures and bump maps.
This webpage will only discuss the science of how basic 3D models are created, with code examples that you can use and take away (and iron out the bugs on your own)! ;)
So in effect, you should be able to read this page, get a brief idea how 3D projection works and create them yourselves with hardly any effort at all.
Then you can apply this new found knowledge to create:

  • 3D models
  • 3D animations
  • 3D placeholders (for forests, crowds, interstellar bodies etc.)
  • Snowfall and rainstorms
  • Retro demos
  • Starfields
  • Parallax scrolling
  • etc. etc. the list is endless.
  • On with the show we go.


    ==The Human Aspect==

    How do we see? How do we see in depth? How do we judge distance?

    Well, the answer to all them questions (well, the last two, not the first question, you can Google that one yourselves) lies within having two eyes and viewing the world in "stereo" so that we experience perspective. A bit like how our ears hear sound, i.e. when you hear a sound you can tell where it is coming from (if you can hear in both ears) due to it being slightly louder in one ear than the other.
    With the eyes though, what happens is each eye sees a different view of the world.

    eyes diagram

    The diagram above, which I stole from Gerbil's Academy of Fine Art, shows that the eyes looking at the same object, a plastic dinosaur.
    The "focus line" shows what each eye is looking at, and what I have labeled the "image plane" is effectively the "frame" that each eye sees when looking at that object. So, each eye sees the same object, but from a different point of view. The "exaggerated" inset photos represent these "image planes". I use the word 'exaggerated' as the difference would be more subtle due to the eyes being roughly two inches apart (or about six inches if you are my ex-landlord).
    The eyes see their own individual image of the world, and the brain merges them together so that us, as humans, can differentiate what is in the foreground and background, thus creating the sensation of depth.

    Try one of these simple fun experiments:

  • Hold your left forefinger in front of your face pointing up.
  • Hold your right forefinger directly behind the left forefinger, about a foot away, also pointing up.
  • Now focus on the right finger. You should be "aware" there are "two" left forefingers in the scene, one on each side of the right forefinger.
  • Now close your left eye and keep your right eye open, without moving your hands. You should notice that the left forefinger appears to be on the left-hand-side of the right forefinger.
  • Now close your right eye and open your left eye. You should notice that the right forefinger appears to be on the left-hand-side of the left forefinger.
  • By alternating between eyes being opened and closed you will notice the fingers appearing to swap places
  • or try this one this one...

  • Point your forefingers towards each other so that the fingertips touch.
  • Raise them so that they are about eye level. What you see is two fingers touching. Big deal! Nowt interesting to report here I hear you say.
  • Now, without moving your hands, focus upon the laptop/monitor/phone screen that you are currently reading from. As you focus on the screen, you will be aware that an "extra bit" of your fingers has been created making it look like a string of sausages.
  • This is due to the focus of the eye's attention moving to a more distant point, increasing that 'depth' point of view.
    This is how stereograms and stereo-images work, they force the eyes into seeing two separate photos that have been taken at the same time but at a distance of about two inches apart.

    And this is what we need to emulate in order to make a 3D image look good on a 2D screen.

    So how is this achieved?


    ==Similar Triangles==

    Similar triangles! That's how! :)

    The 'similar triangles' method of working out projecting 3D coordinates onto a 2D image plane is really easy and very effective! There may be more efficient and more elegant ways of doing this nowadays with 3D graphics, but most info on this page (and the code) has been stripped from an old university project of mine, and does work.

    similar triangles diagram

    The image above perfectly shows the relationship between the 'world' (the 3D model coordinates) and the 'image' (the 2D coordinates on the monitor screen).
    Before I go on, let me make a few things clear regarding coordinates.

  • The x axis. Think back to your days at school and you drew graphs. The x axis denotes the horizontal plane. A person will see this as 'the horizon' - a straight line that spans from left to right. If you see something move in the x axis, it will move from left to right, or right to left.
  • The y axis. This is basically up and down. A person will see this as... well... up and down. No other direction.
  • The z axis. Ooh! A new one! If you did maths at school, chances are you only drew graphs in a 2D plane. Unless you had some magical 'defeats all logic and physics' kind of pencil, you would have also drawn in a 3rd dimension, but chances are you didn't. The z axis can be described as forward and backwards, front and back, in, out, in, out, shake it all about. You do the hokey-cokey and you turn around, that's what it's all about.
  • The u coordinate. This is the derived horizontal (x) position on the monitor screen.
  • The v coordinate. This is the derived vertical (y) position on the monitor screen.
  • Anyway, back to the diagram.
    The triangle shows "what the camera sees" between the camera (the eye) and an object in the 3D world. The camera location is represented as X,Y and Z (note the capitalisation to keep things differentiable). Points in the world space are represented as x,y and z.

    Note: The image above is taken from the point of view of the x plane, i.e. left-right is the z axis. To make things easy, I will explain things using the diagram as a reference and work things out in this dimension only.

    Between the object and the camera is the virtual image, the 'screen' where the image will be drawn. Points on the screen are represented as u (horizontal) and v (vertical).
    What we need to do is find out the value of v for a single point of an object in the 3D world space to be projected.
    From looking at the diagram, we can simply draw a straight line from the camera location to the point of the 3D object we want to draw. And guess what?!? Where that line dissects the virtual image, that's where the point is drawn! Easy! :)
    So how do we do this mathematically? Well as mentioned the answer is to use similar triangles. Look at the diagram once again and do the maths...
    Let z represent the horizontal distance of the 3D object we wish to project; y represent the vertical axis of the 3D object; v represent the vertical axis of the virtual screen; d represent the distance of the virtual screen from the camera. We assume this to be 1. Why? This will be explained later.

    As the triangles are similar, i.e. they share the same angle sizes AND they are right-angles, we can simply say:
    v/d = y/z

    But as we only want to find the value of v, we can multiply both sides by d, thus rewriting as follows:
    v = d(y/z)

    Now we know the screen is a virtual screen, therefore the distance between the screen and the camera isn't important. So we can give the value of 1 to d, thus simplifying the equation even further:
    v = y/z

    and there we have it, deriving a vertical point from a 3D object. We can also do the same to derive the value of u in the x axis:
    u/d = x/z u = d(x/z) u = x/z

    This gives us the basis for the formula to print 3D points onto a 2D screen:

    u = x/z and v = y/z

    How simple is that?!?
    If we were to plot the point (10,20,30) then this would appear at (0.333,0.666), which rounded would be (0,1).
    If we were to plot the second point (100,20,30) then this would appear at (3.333,0.666), which rounded would be printed at (4,1).

    As we can see, this will squeeze everything into a corner, not to mention 3D coordinates with negative values, which are just as important than positive values!
    We get round this by adding the camera elements, i.e. the location of where it is and integrating this into the resulting equations above.


    ==Adding Camera Elements==

    We've worked out the equations to get a 3D object onto a 2D screen, all we need to do now is tweak this to work well with the screen that is being used. I'm keeping things simple and not going to add camera rotations to this page as let's face it - rotating the camera is the same as rotating the 3D world space, and equally processor intensive.

    As mentioned, the camera location is at (X,Y,Z). This means that when the location of the 2D coordinates are worked out, we also need to integrate the camera location so things look more realistic.
    In my opinion, and some may argue otherwise, the best way to do this is to actually know the screen size (or view port) you are working with, then half that. For example, if the screensize is 1200x600 (old skool I know) then set X = 600 and Y = 300.
    That way, when you project something at (0,0,0), it will appear in the middle of the screen. I also set the camera location as constants (hence the CAPITALS in these examples).

    "What do we set Z as?" I hear you ask. Well, be sensible. Play around with it. I personally like to set Z to be something close to the other values, so using a value such as 600 for the above example would probably be fine.
    WORD OF WARNING! Remember we said d = 1 in the above description? Well, this is true and kind of sets that bit up as a constant. So what does this mean? It means that if we set Z too low, the resulting image would looked "stretched" across the screen, as if you was looking at the scene through the eyes of an alien or a landlord; and if you set Z to be too high the scene would looked stretched in the forwards and backwards direction (a spinning cube wouldn't be a cube, it would keep changing, and look crap). So choose a value that looks good. So to integrate the camera into the equations we currently have:
    u = x/z and v = y/z

    We simply multiply the numerator by Z, and add Z to the denominator; Then we add the camera constant of whatever plane we are working out, i.e. add X to u and add Y to v. So the above becomes:
    u = Z(x/(z+Z)) + X and v = Z(y/(z+Z)) + Y

    And that's it!! That's all we need to know about projecting a 3D object onto a 2D screen.

    But what about rotating and translating those objects? That's another one for you to Google. :P Or you could look at the following code below that will show this.
    As I am not from Ancient Greece, I cannot describe the workings of sin and cos, but if you know the graphs that they make then you can easily use them as I did to rotate your objects with ease.


    ==The function==

    What follows is the almighty, all powerful, all fantastic function to project a 3D point onto a 2D space.
    This has been ripped straight from the texture mapping section of my university project from about 20 years ago (that's around about 1998). This is written in C.
    All you need to add is a structure that holds 6 elements (3 for the X, Y and Z positions and 3 for the X, Y and Z rotations).
    Or you could modify this. Either way, the code itself is all you need to project a 3D point onto a 2D space. It is a bit cumbersome, but I have not optimized it as this is a learning page so it will be better to understand as it is.
    Feel free to get in touch and/or acknowledge me if you use this code, either optimised or in its current form - I'd love to see your projects. :)

    void plot3d(struct coords *c){ double xold=0.0,yold=0.0,zold=0.0; //these will hold the initial values (obviously) double xnew=0.0,ynew=0.0,znew=0.0; //these will hold the new values (also obviously) xold=c->x; //store the values from the given 3D object (c) yold=c->y; zold=c->z; // X-Rotation { ynew=((yold*cos(c->rotx))+(zold*sin(c->rotx))); //if a rotation is given, the value will change. znew=((zold*cos(c->rotx))-(yold*sin(c->rotx))); } xold=xold; //store the new values into the old positions ready for the next set of calculations. yold=ynew; zold=znew; // Y-Rotation { xnew=((xold*cos(c->roty))+(zold*sin(c->roty))); //if a rotation is given, the value will change. znew=((xold*sin(c->roty))-(zold*cos(c->roty))); } xold=xnew; //store the new values into the old positions ready for the next set of calculations. yold=yold; zold=znew; // Z-Rotation { xnew=((xold*cos(c->rotz))-(yold*sin(c->rotz))); //if a rotation is given, the value will change. ynew=((xold*sin(c->rotz))+(yold*cos(c->rotz))); } c->scrx= Z*(xnew/(znew+Z)) + X; //derive the horizontal (u) screen coordinate. c->scry= Z*(ynew/(znew+Z)) + Y; //derive the vertical (v) screen coordinate. }



    ==Working Examples==

    Javascript Example

    The code below explains how to create the following cube to rotate a cube on a webpage.

    The cube is a solid wireframe with no shading, so you might see the cube as a "weird rotating trapezoid blob". This isn't my code being wrong, it's your brain getting the perspective all wrong! Seriously! Just keep watching and it will eventually sort itself out (or turn into a blob if you see a cube first time).

    ==The code==

    <canvas id="cubeCanvas" width="640" height="480"></canvas> <script> window.requestAnimFrame = (function(callback) { return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60); }; })(); const X = 320; //Camera coordinates (half of screen) const Y = 240; const Z = 320; function createNewPoint(x,y,z) { //Creates a point in 3D space var obj = {}; obj.x = x; obj.y = y; obj.z = z; obj.rotx = 0.0; obj.roty = 0.0; obj.rotz = 0.0; obj.scrx = 0; obj.scry = 0; obj.translate = function(tx,ty,tz) { //method to move cube about (not used in this example) obj.x += tx; obj.y += ty; obj.z += tz; obj.rotate(obj.rotx, obj.roty, obj.rotz); //calls rotate to reset the screen coords (very lazy) }; obj.rotate = function(rotx,roty,rotz) { //The magic function! var xold=0.0, yold=0.0, zold=0.0; var xnew=0.0, ynew=0.0, znew=0.0; obj.rotx = rotx % 360.0; //store angle given (degrees) obj.roty = roty % 360.0; obj.rotz = rotz % 360.0; rotx = degToRad(rotx); roty = degToRad(roty); rotz = degToRad(rotz); xold=obj.x; //Store old value of the point (x,y,z) yold=obj.y; zold=obj.z; // X-Rotation { ynew=((yold * Math.cos(rotx))+(zold * Math.sin(rotx))); znew=((zold * Math.cos(rotx))-(yold * Math.sin(rotx))); } xold=xold; yold=ynew; zold=znew; // Y-Rotation { xnew=((xold * Math.cos(roty))+(zold * Math.sin(roty))); znew=((xold * Math.sin(roty))-(zold * Math.cos(roty))); } xold=xnew; yold=yold; zold=znew; // Z-Rotation { xnew=((xold * Math.cos(rotz))-(yold * Math.sin(rotz))); ynew=((xold * Math.sin(rotz))+(yold * Math.cos(rotz))); } obj.scrx= Z * (xnew/(znew+Z)) + X; //derive the horizontal (u) screen coordinate. obj.scry= Z * (ynew/(znew+Z)) + Y; //derive the vertical (v) screen coordinate. }; return obj; } function degToRad(degree) { //because I've used degrees since birth return ((Math.PI/180.0)*(degree%360.0)); } function createCube(x,y,z,width,height) { var points = [ createNewPoint(x-(width/2), y-(height/2), z-(width/2)), //A A->B->C->D->A = top plane of cube createNewPoint(x+(width/2), y-(height/2), z-(width/2)), //B createNewPoint(x+(width/2), y-(height/2), z+(width/2)), //C createNewPoint(x-(width/2), y-(height/2), z+(width/2)), //D createNewPoint(x-(width/2), y+(height/2), z-(width/2)), //E E->F->G->H->E = bottom plane of cube createNewPoint(x+(width/2), y+(height/2), z-(width/2)), //F createNewPoint(x+(width/2), y+(height/2), z+(width/2)), //G A->E, B->F, C->G, D->H = vertical planes createNewPoint(x-(width/2), y+(height/2), z+(width/2))]; //H for (i=0;i<points.length;i++) { //sets the "default" screen coords for each points[i].rotate(0,0,0); //default - no rotation } return points; } function drawCube(cube, context) { context.beginPath(); context.moveTo(cube[0].scrx, cube[0].scry); //top plane of cube context.lineTo(cube[1].scrx, cube[1].scry); context.lineTo(cube[2].scrx, cube[2].scry); context.lineTo(cube[3].scrx, cube[3].scry); context.lineTo(cube[0].scrx, cube[0].scry); context.moveTo(cube[4].scrx, cube[4].scry); //bottom plane of cube context.lineTo(cube[5].scrx, cube[5].scry); context.lineTo(cube[6].scrx, cube[6].scry); context.lineTo(cube[7].scrx, cube[7].scry); context.lineTo(cube[4].scrx, cube[4].scry); context.moveTo(cube[0].scrx, cube[0].scry); //side planes of cube context.lineTo(cube[4].scrx, cube[4].scry); context.moveTo(cube[1].scrx, cube[1].scry); context.lineTo(cube[5].scrx, cube[5].scry); context.moveTo(cube[2].scrx, cube[2].scry); context.lineTo(cube[6].scrx, cube[6].scry); context.moveTo(cube[3].scrx, cube[3].scry); context.lineTo(cube[7].scrx, cube[7].scry); context.stroke(); } function animate(cube, canvas, context, startTime) { for(i=0; i < cube.length; i++){ cube[i].rotate(cube[i].rotx + 1, cube[i].roty + 3, cube[i].rotz + 1); //rotate each angle (1,3,1) // cube[i].translate(1,0,-1); //move cube about, but not in this example } // clear screen context.clearRect(0, 0, canvas.width, canvas.height); drawCube(cube, context); // request new frame requestAnimFrame(function() { animate(cube, canvas, context, startTime); }); } var canvas = document.getElementById('cubeCanvas'); var context = canvas.getContext('2d'); var cube = createCube(0,0,0,100,100); //position (0,0,0), width 100, height 100, drawCube(cube, context); // wait one hundredth of a second before starting animation setTimeout(function() { var startTime = (new Date()).getTime(); animate(cube, canvas, context, startTime); }, 10); //1000 = 1 second </script>

    Shell (terminal) Example

    The example here will create a spinning cube that is run in a linux bash terminal.
    This works well on arch linux and ubuntu, but not tested on anything else.
    NOTE! ncurses libraries need to be installed for this example to work! Simply copy the code below into a new file (for example text3D.c) and run the following:
    gcc -o text3D text3D.c -lncurses -lm -g

    If you have problems, use Google. :P
    If all does go well and it compiles successfully, run the program using the following:
    ./text3D

    And there you have it. A crappy looking cube spinning in a bash terminal. :)

    ==The code==

    #include <stdio.h> #include <math.h> #include <stdlib.h> #include <ncurses.h> #include <unistd.h> // Text Based Spinning Cube. // ------------------------- // This is a program that will use ncurses to rotate a cube that // is defined by letters in the corners and a bit of text in the // middle. // This requires the ncurses libraries to be installed, and is // run in a bash terminal! How's that for retro?!?! :D // // compile and link: gcc -o text3D text3D.c -lncurses -lm -g #define AUTHOR "gerbil" #define DATE "19/01/2018" #define VERSION "0.A" typedef struct coords{ double x; double y; double z; double rotx; double roty; double rotz; double scrx; //position on screen double scry; unsigned char ch; }coords; /* Camera */ const double X=50.0; /* These are measured in characters, not pixels */ const double Y=22.0; const double Z=500.0; /* Window size */ const double WINX=100; const double WINY=40; /* Functions used */ double degToRad(double degree){ return ((M_PI/180.0)*fmod(degree,360.0)); } void textPlot3d(struct coords *c){ double xold=0.0,yold=0.0,zold=0.0; double xnew=0.0,ynew=0.0,znew=0.0; xold=c->x; yold=c->y; zold=c->z; // X-Rotation { ynew=((yold*cos(c->rotx))+(zold*sin(c->rotx))); znew=((zold*cos(c->rotx))-(yold*sin(c->rotx))); } xold=xold; yold=ynew; zold=znew; // Y-Rotation { xnew=((xold*cos(c->roty))+(zold*sin(c->roty))); znew=((xold*sin(c->roty))-(zold*cos(c->roty))); } xold=xnew; yold=yold; zold=znew; // Z-Rotation { xnew=((xold*cos(c->rotz))-(yold*sin(c->rotz))); ynew=((xold*sin(c->rotz))+(yold*cos(c->rotz))); } c->scrx= Z*(xnew/(znew+Z)) + X; c->scry= Z*(ynew/(znew+Z)) + Y; } void setObject(struct coords *c,unsigned char ch, double x,double y,double z,double rx,double ry,double rz){ c->x=x; c->y=y; c->z=z; c->rotx=degToRad(rx); c->roty=degToRad(ry); c->rotz=degToRad(rz); c->ch=ch; c->scrx=0.0; c->scry=0.0; textPlot3d(c); } void rotateModelBy(struct coords *c,int size, double x, double y, double z){ int i=0; for(i=0;i<size;i++){ c[i].rotx+=degToRad(x); c[i].roty+=degToRad(y); c[i].rotz+=degToRad(z); textPlot3d(&c[i]); } } void printModel(WINDOW *w, struct coords *c,int size){ int i=0, x=0, y=0; double z=0.0; wclear(w); for(i=0;i<size;i++){ x=(signed int) c[i].scrx; y=(signed int) c[i].scry; z=c[i].z; if((x<WINX && x> 0)&&(y<WINY && y> 0)&&(z<Z && z>-Z)){ //clipping! :) wmove(w,y,x); wprintw(w,"%c",c[i].ch); } } wrefresh(w); } int main(){ bool running = true; double rot=0.0,cycles=0.0; WINDOW *win; double rotation = 0.0; struct coords ob[30]; initscr(); /* Start curses mode */ curs_set(0); win = newwin(WINY, WINX, 0, 0); //The next 30 lines is just to get something "out there". This will be replaced by function addText setObject(&ob[0],'a',-10.0,-10.0,10.0,0.0,0.0,0.0); //a = tl Cube corners setObject(&ob[1],'b',10.0,-10.0,10.0,0.0,0.0,0.0); //b = tr setObject(&ob[2],'c',10.0,10.0,10.0,0.0,0.0,0.0); //c = bl setObject(&ob[3],'d',-10.0,10.0,10.0,0.0,0.0,0.0); //d = br setObject(&ob[4],'e',-10.0,-10.0,-10.0,0.0,0.0,0.0); //a = tl setObject(&ob[5],'f',10.0,-10.0,-10.0,0.0,0.0,0.0); //b = tr setObject(&ob[6],'g',10.0,10.0,-10.0,0.0,0.0,0.0); //c = bl setObject(&ob[7],'h',-10.0,10.0,-10.0,0.0,0.0,0.0); //d = br setObject(&ob[8],'g',-5.0,0.0,0.0,0.0,0.0,0.0); //a = tl //gerbilByte setObject(&ob[9],'e',-4.0,0.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[10],'r',-3.0,0.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[11],'b',-2.0,0.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[12],'i',-1.0,0.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[13],'l',0.0,0.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[14],'B',1.0,0.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[15],'y',2.0,0.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[16],'t',3.0,0.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[17],'e',4.0,0.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[18],'S',-4.0,-5.0,0.0,0.0,0.0,0.0); //a = tl //Spinning setObject(&ob[19],'P',-3.0,-5.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[20],'I',-2.0,-5.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[21],'N',-1.0,-5.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[22],'N',0.0,-5.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[23],'I',1.0,-5.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[24],'N',2.0,-5.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[25],'G',3.0,-5.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[26],'2',-2.0,5.0,0.0,0.0,0.0,0.0); //a = tl //2018 setObject(&ob[27],'0',-1.0,5.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[28],'1',0.0,5.0,0.0,0.0,0.0,0.0); //a = tl setObject(&ob[29],'8',1.0,5.0,0.0,0.0,0.0,0.0); //a = tl printModel(win,ob,30); wmove(win,5,15); wprintw(win,"-=Set window size to (100x40)=-"); wmove(win,6,25); wprintw(win,"then"); wmove(win,7,18); wprintw(win,"-=PrEsS AnY KeY=-"); wgetch(win); for(cycles=0;cycles< 20;cycles++){ for(rot=0.0;rot< 360.0;rot++){ rotateModelBy(ob,30,1,1,1); printModel(win,ob,30); wrefresh(win); usleep(6000); } } delwin(win); endwin(); return 0; }


    There you go folks - that's all from me.

    Have fun,

    gerbil
    ----

    Next week I'll be teaching you how to make a realistic looking space rocket using an empty bottle and a bit of foil, and discuss why drinking Special Brew at the bus stop before going to work isn't the best way to start your day.





    Did you learn anything from this page? Buy me a coffeeBuy me a coffee

    Powered by Caffeinated Projects.