Lighting a 3D object in Flash, pt 2
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) :
1 2 3 | //pt1,2,3 are Vector3D objects representing points of a triangle var d1:Vector3D = pt3.subtract(pt1); var d2: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.
1 2 3 | //get the cross-product of the results to get the normal var normal: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.
1 | var angleDiff:Number = Vector3D.angleBetween(lightDir,normal); |
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.
flash 10 required
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | package com.dafishinsea.tutorials.normalmap { import flash.display.Bitmap; import flash.display.BitmapData; import flash.display.Shape; import flash.display.Sprite; import flash.display.StageAlign; import flash.display.StageScaleMode; import flash.display.TriangleCulling; import flash.events.Event; import flash.geom.Matrix3D; import flash.geom.PerspectiveProjection; import flash.geom.Point; import flash.geom.Rectangle; import flash.geom.Utils3D; import flash.geom.Vector3D; import net.hires.debug.Stats; [SWF(backgroundColor="0xffffff", width="800", height="600", frameRate="30")] public class NormalMap2 extends Sprite { private var texture:BitmapData = new BitmapData(500,500,false,0xFF0000); private var vertices:Vector.<Number>; private var indices:Vector.<int>; private var uvtData:Vector.<Number>; private var perspective: PerspectiveProjection; private var projectionMatrix : Matrix3D; private var rotationMatrix : Matrix3D; private var projectedVerts:Vector.<Number>; private var focalLength:Number = 50; private var container:Sprite; private const PI:Number = Math.PI;//half revolution in radians private const HALFPI:Number = Math.PI/2;//1/4 revolution in radians private const TWOPI:Number = 2*Math.PI;//full revolution in radians //x,yz rotation private var rx:Number = 60; private var ry:Number = 40; private var rz:Number = 30; private var testBmp:Bitmap; private var r:Number = 0; public function NormalMap2() { init(); } private function init():void { stage.align = StageAlign.TOP_LEFT; stage.scaleMode = StageScaleMode.NO_SCALE; vertices = new Vector.<Number>(); indices = new Vector.<int>(); uvtData = new Vector.<Number>(); //set up perspective perspective = new PerspectiveProjection(); perspective.fieldOfView = 50; //3D transformation matrix - used to rotate object projectionMatrix = perspective.toMatrix3D(); rotationMatrix = new Matrix3D();//used to keep track of rotation of the sphere //using separate one since Matrix3D derived from perspectiveProjection is not invertible projectedVerts = new Vector.<Number>(); //container to hold scene container = new Sprite(); 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(new Stats()); } private function createSphere(radius:Number, rows:int, cols:int):void { var lon_incr:Number = TWOPI/cols; var lat_incr = PI/rows; var lon:Number = 0;//angle of rotation around the y axis, *in radians* var lat:Number = 0;//angle of rotation around the x axis var x:Number, y:Number, z:Number; var vnum:int = 0; var ind:int = 0; //a full rotation is PI radians for(var h:int = 0; h <= rows; ++h) { y = radius*Math.cos(lat);//need to shift angle downwards by 1/4 rev for(var v: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 triplet vertices[vnum] = x; vertices[vnum+1] = y; vertices[vnum+2] = z; //uvts uvtData[vnum] = v/cols; uvtData[vnum+1] = h/rows; uvtData[vnum+2] = 1; vnum+=3; //add indices if(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; } } private function onEnterFrame(event:Event):void { //createSphere(100, 20, 40); update(); render(); } private function update():void { r+=0.5; rotationMatrix = new Matrix3D(); projectionMatrix.prependTranslation(0.0,0.0,250); rotationMatrix.prependRotation(-r, new Vector3D(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, new Vector3D(0,1,0)) } private function render():void { //update texture map based on triangle normals var tr:Shape = new Shape(); //for(var i:int = 0; i < indices.length; i+=3){ for(var i:int = 0; i < indices.length; i+=3){ //get the 3 points of the triangle as Vector3D objects var pt1:Vector3D = new Vector3D(vertices[indices[i]*3], vertices[indices[i]*3+1], vertices[indices[i]*3+2]); var pt2:Vector3D = new Vector3D(vertices[indices[i+1]*3], vertices[indices[i+1]*3+1], vertices[indices[i+1]*3+2]); var pt3:Vector3D = new Vector3D(vertices[indices[i+2]*3], vertices[indices[i+2]*3+1], vertices[indices[i+2]*3+2]); //subtract the first two from the third var d1:Vector3D = pt3.subtract(pt1); var d2:Vector3D = pt3.subtract(pt2); //get the cross-product of the results to get the normal var normal:Vector3D = d1.crossProduct(d2); normal.normalize(); //get the angle between the normal and the lighting angle var lightDir:Vector3D = rotationMatrix.transformVector(new Vector3D(1000,1000,1000)); //TODOtransform the light dir by the sphere matrix var angleDiff:Number = Vector3D.angleBetween(lightDir,normal); //trace("angleDiff:"+angleDiff); //normalize shade to 0-1 var r:int = isNaN(angleDiff)?255:255*angleDiff/Math.PI; var shadow:uint = r << 16 | r << 8 | r; //draw shaded texture tr.graphics.beginFill(shadow); var p1:Point = new Point(uvtData[indices[i]*3]*texture.width, uvtData[indices[i]*3+1]*texture.height); var p2:Point = new Point(uvtData[indices[i+1]*3]*texture.width, uvtData[indices[i+1]*3+1]*texture.height); var p3:Point = new Point(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 texture texture.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…