Da Fish in Sea

These are the voyages of Captain Observant

Lighting a 3D Object in Flash, Pt 2

| Comments

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…