OK, so we have a sphere, now how about some lighting… Basically what we need to do is determine the angle of each surface (the normal) and then the difference between that angle and the angle of light. The greater the difference, the brighter the surface should be. If this doesn’t make sense then think of how a surface whose normal was the same as the angle of the light would be pointing away from the light, and should be totally dark.
A while back I came up with a solution for lighting a mesh object using the Flash 10 3D APIs. This was my own idea based on the knowledge that the normal of a triangle can be computed by getting a vector which is perpendicular to two of its sides. The following crude diagram may help.. the triangle is ABC, the normal is AN. The normal is considered to be facing away from the surface. You’ll need to imagine that ABC is not flat on the picture plane, with B pointing away from you, and with NA perpendicular to AC and AB, which it doesn’t really look like it is in the picture, but theres only so much you can do with ASCII art :p
N
\ B
\ /|
\ / |
\ / |
\/___|
A C
So the plan then is to iterate over every triangle in the sphere, and determine its normal, and then the luminosity of that surface by getting the difference between it and the light. The math for this involves vectors, and was new stuff for me. I recommend the excellent book ‘3D Math Primer for Graphics and Game Development’, but any introductory text to 3D graphics should be helpful to grok these concepts. Fortunately the Vector3D class has some handy methods to do such calculations, and you just need to know which method to call.
To get the normal of a triangle you need to convert the points to vectors, and then get the cross-product between two of these vectors.I just created 3 Vector3D objects for each point and subtracted them from each other to get the vectors representing the sides of the triangle. Eg. subtracting point A from B in the above triangle, gives you the vector representing AB. Note that you need to use the Vector3D subtract() method, not regular ‘-’ since the vector subtract() subtracts each component of the vector for you. At this point the code may help clarify (I will give complete code later) :
123
//pt1,2,3 are Vector3D objects representing points of a trianglevard1:Vector3D=pt3.subtract(pt1);vard2:Vector3D=pt3.subtract(pt2);
Then the final step to get the normal of the triangle surface is to get the cross-product of the two vectors we just derived.
123
//get the cross-product of the results to get the normalvarnormal:Vector3D=d1.crossProduct(d2);normal.normalize();
The normalize() method just makes sure the length of the vector is between -1 & 1. Now to get the difference between the surface normal and the light vector, we can use the angleBetween() method.
Then I converted the this difference to a color, and draw it onto the bitmapData being used as the texture of the object. I just used the drawing API to draw each triangle on a Sprite, then drew the sprite onto the bitmapData being used in the beginFill() method just before the call to drawTriangles() in the render method.
When I first did this I found that when I rotated the sphere, the lighting was rotating along with it. So I had to transform the light vector using the Matrix3D which I was using to rotate the sphere. This did not work as expected. Then I realized I had to transform the light vector using a Matrix3D which was the inverse of the rotation of the sphere (the projection matrix). So I thought I could just call the invert() method on the projection matrix and use that to transform the light. But it turns out that the projection matrix, which is derived from a perspective projection, is not invertible. SO… I had to make another Matrix3D, and rotate it in the opposite direction, in order to use it for correcting the light direction.
Here’s the working example, followed by the code. There’s a lot of it, but that will improve soon… see comments after code.
packagecom.dafishinsea.tutorials.normalmap{importflash.display.Bitmap;importflash.display.BitmapData;importflash.display.Shape;importflash.display.Sprite;importflash.display.StageAlign;importflash.display.StageScaleMode;importflash.display.TriangleCulling;importflash.events.Event;importflash.geom.Matrix3D;importflash.geom.PerspectiveProjection;importflash.geom.Point;importflash.geom.Rectangle;importflash.geom.Utils3D;importflash.geom.Vector3D;importnet.hires.debug.Stats;[SWF(backgroundColor="0xffffff",width="800",height="600",frameRate="30")]publicclassNormalMap2extendsSprite{privatevartexture:BitmapData=newBitmapData(500,500,false,0xFF0000);privatevarvertices:Vector.<Number>;privatevarindices:Vector.<int>;privatevaruvtData:Vector.<Number>;privatevarperspective:PerspectiveProjection;privatevarprojectionMatrix:Matrix3D;privatevarrotationMatrix:Matrix3D;privatevarprojectedVerts:Vector.<Number>;privatevarfocalLength:Number=50;privatevarcontainer:Sprite;privateconstPI:Number=Math.PI;//halfrevolutioninradiansprivateconstHALFPI:Number=Math.PI/2;//1/4revolutioninradiansprivateconstTWOPI:Number=2*Math.PI;//fullrevolutioninradians//x,yz rotationprivatevarrx:Number=60;privatevarry:Number=40;privatevarrz:Number=30;privatevartestBmp:Bitmap;privatevarr:Number=0;publicfunctionNormalMap2(){init();}privatefunctioninit():void{stage.align=StageAlign.TOP_LEFT;stage.scaleMode=StageScaleMode.NO_SCALE;vertices=newVector.<Number>();indices=newVector.<int>();uvtData=newVector.<Number>();//set up perspectiveperspective=newPerspectiveProjection();perspective.fieldOfView=50;//3D transformation matrix - used to rotate objectprojectionMatrix=perspective.toMatrix3D();rotationMatrix=newMatrix3D();//used to keep track of rotation of the sphere//using separate one since Matrix3D derived from perspectiveProjection is not invertibleprojectedVerts=newVector.<Number>();//container to hold scenecontainer=newSprite();container.x=stage.stageWidth/2;container.y=stage.stageHeight/2;addChild(container);//add test bitmap//testBmp = new Bitmap(texture);//container.addChild(testBmp);createSphere(100,20,40);addEventListener(Event.ENTER_FRAME,onEnterFrame);//update();//render();addChild(newStats());}privatefunctioncreateSphere(radius:Number,rows:int,cols:int):void{varlon_incr:Number=TWOPI/cols;varlat_incr=PI/rows;varlon:Number=0;//angleofrotationaroundtheyaxis,*inradians*varlat:Number=0;//angleofrotationaroundthexaxisvarx:Number,y:Number,z:Number;varvnum:int=0;varind:int=0;//a full rotation is PI radiansfor(varh:int=0;h<=rows;++h){y=radius*Math.cos(lat);//need to shift angle downwards by 1/4 revfor(varv:int=0;v<=cols;++v){x=radius*Math.cos(lon)*Math.sin(lat);z=radius*Math.sin(lon)*Math.sin(lat);//seen from above, z = y//add vertex tripletvertices[vnum]=x;vertices[vnum+1]=y;vertices[vnum+2]=z;//uvtsuvtData[vnum]=v/cols;uvtData[vnum+1]=h/rows;uvtData[vnum+2]=1;vnum+=3;//add indicesif(h<rows&&v<cols){indices.push(ind,ind+1,ind+cols+1);indices.push(ind+cols+1,ind+1,ind+cols+2);}ind+=1;lon+=lon_incr;}lat+=lat_incr;}}privatefunctiononEnterFrame(event:Event):void{//createSphere(100, 20, 40);update();render();}privatefunctionupdate():void{r+=0.5;rotationMatrix=newMatrix3D();projectionMatrix.prependTranslation(0.0,0.0,250);rotationMatrix.prependRotation(-r,newVector3D(0,1,0))//container.x = stage.stageWidth/2 ;//container.y = stage.stageHeight/2 ;projectionMatrix=perspective.toMatrix3D();projectionMatrix.prependTranslation(0.0,0.0,250);projectionMatrix.prependRotation(r,newVector3D(0,1,0))}privatefunctionrender():void{//update texture map based on triangle normalsvartr:Shape=newShape();//for(var i:int = 0; i < indices.length; i+=3){for(vari:int=0;i<indices.length;i+=3){//get the 3 points of the triangle as Vector3D objectsvarpt1:Vector3D=newVector3D(vertices[indices[i]*3],vertices[indices[i]*3+1],vertices[indices[i]*3+2]);varpt2:Vector3D=newVector3D(vertices[indices[i+1]*3],vertices[indices[i+1]*3+1],vertices[indices[i+1]*3+2]);varpt3:Vector3D=newVector3D(vertices[indices[i+2]*3],vertices[indices[i+2]*3+1],vertices[indices[i+2]*3+2]);//subtract the first two from the thirdvard1:Vector3D=pt3.subtract(pt1);vard2:Vector3D=pt3.subtract(pt2);//get the cross-product of the results to get the normalvarnormal:Vector3D=d1.crossProduct(d2);normal.normalize();//get the angle between the normal and the lighting anglevarlightDir:Vector3D=rotationMatrix.transformVector(newVector3D(1000,1000,1000));//TODOtransform the light dir by the sphere matrixvarangleDiff:Number=Vector3D.angleBetween(lightDir,normal);//trace("angleDiff:"+angleDiff);//normalize shade to 0-1varr:int=isNaN(angleDiff)?255:255*angleDiff/Math.PI;varshadow:uint=r<<16|r<<8|r;//draw shaded texturetr.graphics.beginFill(shadow);varp1:Point=newPoint(uvtData[indices[i]*3]*texture.width,uvtData[indices[i]*3+1]*texture.height);varp2:Point=newPoint(uvtData[indices[i+1]*3]*texture.width,uvtData[indices[i+1]*3+1]*texture.height);varp3:Point=newPoint(uvtData[indices[i+2]*3]*texture.width,uvtData[indices[i+2]*3+1]*texture.height);tr.graphics.moveTo(p1.x,p1.y);tr.graphics.lineTo(p2.x,p2.y);tr.graphics.lineTo(p3.x,p3.y);tr.graphics.lineTo(p1.x,p1.y);tr.graphics.endFill();}//copy drawn shadows to bitmap texturetexture.draw(tr);//addChild(tr);container.graphics.clear();Utils3D.projectVectors(projectionMatrix,vertices,projectedVerts,uvtData);container.graphics.beginBitmapFill(texture,null,false,false);container.graphics.drawTriangles(projectedVerts,indices,uvtData,TriangleCulling.POSITIVE);container.graphics.endFill();}}}
So although I was happy that this worked at all, it has some problems… performance has taken a hit, as we are re-creating the shadow/texture map every time. And the ‘texture map’ a this point only has lighting in it , so we’d have to merge the lighting onto a real texture map (with other colors). And the sphere looks more like a disco ball, not smooth at all, so if we wanted a smooth appearance, we’d have to increase the number of points, which would further degrade performance. I did get some improvement by blurring the shadow-map but this would only help with smooth curved surfaces. So this is not a scalable solution at all. I almost didn’t want to show this version at all, but it does introduce the concept of normals, and how to get the lighting on a surface.
After a while it occurred to me that maybe I should really look into normal-maps as a way to improve my lighting code. I was also hoping that I could optimize performance by using PixelBender to do the lighting calculations, based on the normal map. It turned out to be correct, so stay tuned for the next exciting installment…