Da Fish in Sea

These are the voyages of Captain Observant

Lighting a Sphere, Part 4

| Comments

So last time we ended up with a chunky looking ball. Great. Lets spice things up a little by turning it into a planet, and a big one at that: Jupiter. Here is what the end result looks like: (note - slider controls direction of lighting, in the x,y,z planes.. I apologize for the crappy GUI)

flash 10 required

If the planet is not centered, but instead you only see the bottom right part of it in the top left, refresh your browser.

There’s two main changes since last time. Firstly, in the pixelbender kernel I’ve applied the shading value to the color value of a second input image (called texture). Since all color values in pixel bender are between 0 and 1, simply multiplying each channel value by a constant in the same range (0-1) has the effect of lightening or darkening it, without changing the hue. First I’ll just dump out the kernel since it is not that long:

 


kernel NormalMap2TextureMap
<   namespace : "com.dafishinsea";
    vendor : "David Wilhelm";
    version : 2;
    description : "kernel to apply shading to a texture map using a normal map";
>
{
    input image4 src;
    input image4 texture;
    output pixel4 dst;
    parameter float xr
    <
        minValue: -1.0;
        maxValue: 1.0;
        defaultValue: 0.1;
    >;
    parameter float yr
    <
        minValue: -1.0;
        maxValue: 1.0;
        defaultValue: 0.0;
    >;
    parameter float zr
    <
        minValue: -1.0;
        maxValue: 1.0;
        defaultValue: 0.0;
    >;

    void
    evaluatePixel()
    {
        //get currrent pixel color
        pixel4 p = sampleNearest(src,outCoord());
        pixel4 tp = sampleNearest(texture,outCoord());
        //image4 is a 4 element vector of RGBA channels
        //we want a vector of the first three as the normal
        float xd = 2.0*p.r - 1.0;
        float yd = 2.0*p.g - 1.0;
        float zd = 2.0*p.b - 1.0;
        float3 normal = float3(xd, yd, zd);
        normal = normalize(normal);
        float3 light = float3(xr, yr, zr) ;
        light = normalize(light);
        float dotp = dot(light, normal);
        //angle between vectors = acos (a dot b)  
        float angle = acos(dotp);
        float shade = angle/3.14159265358;
        //add contrast
        shade = shade*shade;
        dst = pixel4(shade*tp.r, shade*tp.g, shade*tp.b, 1.0);

    }
}

The xr,yr,zr params are the direction of the light, which we passed in from flash. We have now added a second input image called ‘texture’ - this is a spherical map of jupiter. I scaled it down a bit to 640 x 640. We grab the pixel color on line 37, just as we got the color of the normal map. Then we don’t do anything else until the end, after we have calculated the shading value. Then, instead of giving the result as the shading value, we multiply each channel of the texture by the shading value. I also fixed the glitch in the lighting by normalizing the light and normal values.

Now the other change is that I refactored the createNormalMap() method so that the normal map is generated on a per-pixel basis, by getting the normal of the surface of the sphere at each point (pixel) in the texture map. This results in a much smoother normal map.


/**
 * create normal map --evaluate normal for each pixel based lat/lon at that position in the sphere
 */
private function createNormalMap():void
{
    var radius:Number = 1;
    var lon:Number = 0;
    var lat:Number = 0;
    
    for(var v:int = 0; v < mapHeight; ++v)
    {
        lat = (v/mapHeight)*PI;
        var y3d:Number = radius*Math.cos(lat);

        for(var u:int = 0; u < mapWidth; ++u)
        {
            lon = (u/mapWidth)*TWOPI;
            var x3d:Number = radius*Math.cos(lon)*Math.sin(lat);
            var z3d:Number = radius*Math.sin(lon)*Math.sin(lat);
            //normal is vector from center of sphere (consider = origin) to this point
            var norm:Vector3D = new Vector3D(x3d,y3d,z3d);
            norm.normalize();
            var r:uint = 255*(norm.x + 1)/2;
            var g:uint = 255*(norm.y + 1)/2;
            var b:uint = 255*(norm.z + 1)/2;
            var normalColor:uint = r << 16 | g << 8 | b;
            normalMap.setPixel(u,v,normalColor);
        }
    }
}

And here is the result:

As you can see it is really smooth, which results in smooth lighting, even though our sphere is not a very high-polygon mesh. And I’m getting a reasonable frame rate, though using PixelBender always gets my old MacBook’s fan whirring, since it is does not have a supported graphics card, and does everything on the CPU. Even so, the performance is a good deal better than it would have been without using PixelBender. A further optimization would be to save out the normal map to a file and compile it in to the app – this would certainly be worthwile if you had a lot of 3d objects on stage. Another technique to improve performance of this sort of thing is to dynamically reduce the resolution of the texture map when the size of the projected 3d object changes. No need to process a 640 x 640 texture and normal map, if the object itself is only 100 pixels high. This is known as mipmapping, and I may well explore that later.

Also want to point out that this is very primitive lighting. The first time round I did this, the lighting seemed flat, so I squared the shade value to make it a bit more contrasty. A lot more can be achieved here, as lighting techniques are a whole science..some really cool things can be done. I also wanted to explore generating a rough surface texture on the sphere, but that proved more complex than I initially thought so I’ve decided to try that on a plane first. Coming up next… height maps!

Da Code

NormalMap4.as (GitHub)

NormalMapShader.pbk (GitHub)

Lighting a Sphere Part the Third - Refactoring to Use a Normal Map

| Comments

So last time we ended up with a sphere with lighting, though it was kinda chunky looking as a result of the lighting being derived from the geometry of the sphere. Now lets see if we can decouple the lighting from the geometry. Currently the lighting is done directly on the texture map based on the normals of each triangle. Thus we are calculating the normals of each triangle in the mesh, and then getting the difference between the normal and the direction of the light, in order to derive the intensity of the lighting.

But it is not necessary to recalculate the normals every frame, since they don’t change, at least in the object coordinates. Thus it makes sense to cache the normals somehow - this is where the normal map comes in. So we can refactor our current example by converting the normals of each surface to a color, where the Red, Green, and Blue channels represent the x, y, & z components of the vector. How do you transform a vector into a color? I did it like this:

1
2
3
4
        var r:uint = 255*(normal.x + 1)/2;
        var g:uint = 255*(normal.y + 1)/2;
        var b:uint = 255*(normal.z + 1)/2;
        var normalColor:uint = r << 16 | g << 8 | b;

Since the normal is in the range of -1 to 1, we add one to it to put it in the range of 0 - 2, then divide by 2 to get it in the 0 -1 range. Multiplying by 255 gives a 8-bit value. The we shift these over to the right channels for a RGB color using binary operators. So that gives a normal map.. it actually looks quite pretty… click the buttons in the example below to see the normal map and the texture map (which currently just has the shading in it).

flash 10 required

Another advantage of having the normal map in the form of an image, is that we can use PixelBender to process it to produce the lighting/shading from the combintation of the normals and the light direction. All we have to do is the same calculations we were doing before (explained in previous lession) inside the Pixelbender kernel. As it turns out, PixelBender had lots of builtin functions to help with this kind of calculation, and it pretty straightforward. The kernel is really tiny, so here it is in full:

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
   <languageVersion : 1.0;>

  kernel NormalMap2TextureMap
  <   namespace : "com.dafishinsea";
      vendor : "David Wilhelm";
      version : 1;
      description : "kernel to generate a texture map (shading only) from a normal map";
  >
  {
      input image4 src;
      output pixel4 dst;
      parameter float xr
      <
          minValue: -1.0;
          maxValue: 1.0;
          defaultValue: 0.1;
      >;
      parameter float yr
      <
          minValue: -1.0;
          maxValue: 1.0;
          defaultValue: 0.0;
      >;
      parameter float zr
      <
          minValue: -1.0;
          maxValue: 1.0;
          defaultValue: 0.0;
      >;

      void
      evaluatePixel()
      {
          //get currrent pixel color
          pixel4 p = sampleNearest(src,outCoord());
          //image4 is a 4 element vector of RGBA channels
          //we want a vector of the first three as the normal
          float xd = 2.0*p.r - 1.0;
          float yd = 2.0*p.g - 1.0;
          float zd = 2.0*p.b - 1.0;
          float3 normal = float3(xd, yd, zd);
          float3 light = float3(xr, yr, zr) ;
          float dotp = dot(light, normal);
          //angle between vectors = acos (a dot b)  
          float angle = acos(dotp);
          float clr = angle/3.14159265358;
          dst = pixel4(clr, clr, clr, 1.0);

      }
  }

All this does is convert the normal (color at the source pixel) to a shading color (greyscale) depending on its variance with the lighting direction (which is passed in to the kernel as a parameter – actually 3, for x,y,z components). The huge advantage here is that this process gets called by Actionscript as a ShaderJob, and it runs ansynchronously. The result gets used as the texture for the sphere. I won’t go over the mechanics of using a ShaderJob – check out this article for more info. Or you can just read the code below, for all the gory details.

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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
package com.dafishinsea.tutorials.normalmap {

  import com.bit101.components.*;
  import flash.display.Bitmap;
  import flash.display.BitmapData;
  import flash.display.Shader;
  import flash.display.ShaderJob;
  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.filters.ShaderFilter;
  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 flash.utils.ByteArray;
  import net.hires.debug.Stats;

  [SWF(backgroundColor="0xffffff", width="800", height="600", frameRate="50")]  
  public class NormalMap3 extends Sprite {

      private var texture:BitmapData = new BitmapData(500,500,false,0xFFFFFF);
      private var textureBmp:Bitmap = new Bitmap(texture);
      private var normalMap:BitmapData = new BitmapData(500,500,false,0xFFFFFF);
      private var normalBmp:Bitmap = new Bitmap(normalMap);
      private var normalMapShp:Shape = new Shape();
      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;//yrotation of sphere
      private var normalMapShader:Shader;
      private var shaderJob:ShaderJob;
      private var input:ByteArray;
      private var output:ByteArray;
      private var normalMapRdo:PushButton;
      private var textureMapRdo:PushButton;
      private var noMapsRdo:PushButton;

      [Embed(source="NormalMapShader.pbj", mimeType="application/octet-stream")]
      private var NormalMapShader:Class;

      public function NormalMap3() {
          init();
          trace("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
          projectedVerts   = new Vector.<Number>();
          //container  to hold scene
          container = new Sprite();
          container.x = stage.stageWidth/2;
          container.y = stage.stageHeight/2;
          addChild(container);
          createSphere(100, 40, 80);
          createNormalMap();
          createShaderJob();
          addEventListener(Event.ENTER_FRAME, onEnterFrame);
          addEventListener(Event.RENDER, render, false, 0, true);
          addChild(new Stats());
          //texture map
          //controls
          normalMapRdo = new PushButton(this, 500, 30, "Show normal map", onNomalMapBtnClick);
          textureMapRdo = new PushButton(this, 500, 50, "Show texture map", onTextureRdoClick);
          noMapsRdo = new PushButton(this, 500, 10, "Show only sphere", onNoMapsRdoClick);
      }
      protected function onNomalMapBtnClick(event:Event):void
      {
          if(textureBmp.parent == this)
              removeChild(textureBmp);
          addChild(normalBmp);
      }

      protected function onTextureRdoClick(event:Event):void
      {
          if(normalBmp.parent == this)
              removeChild(normalBmp);
          addChild(textureBmp);
      }

      protected function onNoMapsRdoClick(event:Event):void
      {
          if(normalBmp.parent == this)
              removeChild(normalBmp);
          if(textureBmp.parent == this)
              removeChild(textureBmp);
      }

      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)
          {
              lon = 0;
              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;
          }
      }
      /**
      * create ShaderJob for use later
      */
      private function createShaderJob():void
      {
          normalMapShader = new Shader(new NormalMapShader());
          normalMapShader.data.src.width = normalMap.width;
          normalMapShader.data.src.height = normalMap.height;
          normalMapShader.data.src.input = normalMap;
          
          shaderJob = new ShaderJob(normalMapShader, texture, width, height);
          shaderJob.addEventListener(Event.COMPLETE, shaderJobCompleteHandler);
          shaderJob.start();
      }

      /**
      * shade job complete
      */
      private function shaderJobCompleteHandler(event:Event):void
      {
          //update params and restart job
          var lightDir:Vector3D = rotationMatrix.transformVector(new Vector3D(1,1,1));
          lightDir.normalize();
          normalMapShader.data.xr.value = [lightDir.x];
          normalMapShader.data.yr.value = [lightDir.y];
          normalMapShader.data.zr.value = [lightDir.z];
          shaderJob = new ShaderJob(normalMapShader, texture, width, height);
          shaderJob.addEventListener(Event.COMPLETE, shaderJobCompleteHandler);
          shaderJob.start();
      }

      /**
      * create normal map from geometry to reuse for lighting
      */
      private function createNormalMap():void
      {
          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();
              var r:uint = 255*(normal.x + 1)/2;
              var g:uint = 255*(normal.y + 1)/2;
              var b:uint = 255*(normal.z + 1)/2;
              var normalColor:uint = r << 16 | g << 8 | b;
              normalMapShp.graphics.beginFill(normalColor);
              var p1:Point = new Point(uvtData[indices[i]*3]*normalMap.width, uvtData[indices[i]*3+1]*normalMap.height);
              var p2:Point = new Point(uvtData[indices[i+1]*3]*normalMap.width, uvtData[indices[i+1]*3+1]*normalMap.height);
              var p3:Point = new Point(uvtData[indices[i+2]*3]*normalMap.width, uvtData[indices[i+2]*3+1]*normalMap.height);
              normalMapShp.graphics.moveTo(p1.x, p1.y);
              normalMapShp.graphics.lineTo(p2.x, p2.y);
              normalMapShp.graphics.lineTo(p3.x, p3.y);
              normalMapShp.graphics.lineTo(p1.x, p1.y);
              normalMapShp.graphics.endFill();
          }
          normalMap.draw(normalMapShp);

      }
      /**
      * save the normal map -- so I can play with it in PixelBender Toolkit ;)
      */
      private function onSaveBtnClick(click:Event):void
      {
          var savingBmp:SavingBitmap = new SavingBitmap(normalMap);
          savingBmp.encoderType = "png";
          savingBmp.save("normalMap.png");
          
      }

      private function onEnterFrame(event:Event):void {
          //createSphere()
          update();
      }

      private function update():void {
          r+=0.5;
          rotationMatrix = new Matrix3D();
          rotationMatrix.prependRotation(-r, new Vector3D(0,1,0))
          projectionMatrix = perspective.toMatrix3D();
          projectionMatrix.prependTranslation(0.0,0.0,250);
          projectionMatrix.prependRotation(r, new Vector3D(0,1,0))
          stage.invalidate();//trigger render event

      }
      private function render(e:Event):void {
          
          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();
      }

      
  }
}

You may have noticed that there is a white blotch in the darkest part of the shadow… this is a bug in the shader, which I have yet to figure out… let me know if you know what it is :)

The next step is to liberate the normal map from the actual geometry, in order to make the lighting more detailed than the geometry… the dramatic conclusion to this series is coming soon!

As usual, code is on GitHub…

NormalMap3.as

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…

Lighting a 3D Object in Flash, Pt 1

| Comments

This is the first part in a tutorial on using a normal map to light a sphere in Flash.

First of all, we need some object to light.. for this I revisited the Sphere I did earlier, and improved the method of its construction, using radians instead of degrees, and also generating the indices in the same loop as the vertices. First, here is what it looks like with a flat, outlined fill…

flash 10 required

Since I just installed the snazzy CodeColorer plugin, here’s the code…

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
package com.dafishinsea.tutorials.normalmap
{
  import flash.display.BitmapData;
  import flash.display.Sprite;
  import flash.display.TriangleCulling;
  import flash.events.Event;
  import flash.geom.Matrix3D;
  import flash.geom.PerspectiveProjection;
  import flash.geom.Utils3D;
  import flash.geom.Vector3D;
  import flash.text.TextField;
  /**
  * procedural model of a Pumpkin to demonstrate use of normal map
  */
  [SWF(backgroundColor="0x000000", width="800", height="600", frameRate="20")]
  public class NormalMap1 extends Sprite
  {

      private var vertices:Vector.<Number>;
      private var projectedVerts:Vector.<Number>;
      private var numverts:int;
      private var indices:Vector.<int>;
      private var uvts:Vector.<Number>;
      private var texture:BitmapData;
      private var perspective:PerspectiveProjection;
      private var projMatrix:Matrix3D;
      private var sphereRadius:Number;
      private var sphereCenter:Vector3D;
      private var rows:int;//number of times the sphere is sliced to form segments
      private var cols:int;
      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
      private var canvas:Sprite;
      private var r:Number = 0;
      //the number of segments = num slices - 1
      //this is the same as lines of longitude
      //thus there are twice as many as horizontal slices

      public function NormalMap1()
      {
          init();
          createMesh();
          addEventListener(Event.ENTER_FRAME, onEnterFrame);
          addEventListener(Event.RENDER, render);
          //render();
      }
      private function onEnterFrame(event:Event):void {
          r = r + 0.2;
          prerender();
          stage.invalidate();
      }


      /**
      * initialize common properties
      */
      private function init():void
      {
          perspective = root.transform.perspectiveProjection;
          texture = new BitmapData(800,800,false,0xCCCCCC);
          sphereRadius = 100;
          //sphereCenter = new Vector3D(stage.stageWidth/2, stage.stageHeight/2, 200);
          cols = 40;
          rows = 20;
          numverts = (cols+1)*(rows+1);
          vertices = new Vector.<Number>(numverts*3);//x,y,z for each vertex
          projectedVerts = new Vector.<Number>(numverts*3);
          uvts = new Vector.<Number>(numverts*3);
          indices = new Vector.<int>();
          //we need to work around the fact that Utils3D.projectVectors disregards the projection center
          //of the projectionMatrix it is passed .. it always projects around 0,0
          //so we must render things on a canvas centered on stage
          canvas = new Sprite();
          canvas.x = stage.stageWidth/2;
          canvas.y = stage.stageHeight/2;
          addChild(canvas);
      }

      /**
      * create the mesh of the Sphere
      */
      private function createMesh():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 = sphereRadius*Math.cos(lat);//need to shift angle downwards by 1/4 rev
              for(var v:int = 0; v <= cols; ++v)
              {
                  x = sphereRadius*Math.cos(lon)*Math.sin(lat);
                  z = sphereRadius*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;
                  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;
          }
      }

      /**
      * prerender the sphere -- project vertices using rotated matrix
      */
      private function prerender():void
      {
          projMatrix = perspective.toMatrix3D();
          projMatrix.prependTranslation(0,0,400);
          projMatrix.prependRotation(r, new Vector3D(0,1,0));
          Utils3D.projectVectors(projMatrix, vertices, projectedVerts,uvts);
      }

      /**
      * render
      */
      private function render(event:Event):void
      {
          canvas.graphics.clear();
          canvas.graphics.beginFill(0xFFFFFF);
          canvas.graphics.lineStyle(1,0xcccccc,1);
          canvas.graphics.drawTriangles(projectedVerts, indices, null /* uvts */, TriangleCulling.POSITIVE);
          canvas.graphics.endFill();
      }
  }
}

And if you actually want to download the code, here’s the link:

Da Code

NormalMap1 (@GitHub)

New Games Section

| Comments

Just wanted to announce the launch of the new game section of the site. I added the Space Invaders game which is the first full-on ‘game engine’ I’ve developed. I really wanted to preserve some effects from the original, such as the way the shields get gradually eroded by bullets. Ended up using the BitmapData APIs a lot, both to create these effects, and also to avoid the high CPU usage effect that causes my MacBook to sound like a plane taking off whenever there’s more than a dozen sprites on stage and a semi-decent framerate. Another reason is the awesome Bitmap collision detection which is so fast and easy to do. I’ve tried doing games before, but ended up getting sidetracked by figuring out more and more complex collision detection - and never actually getting the game done - until I realized that most of the arcade games that I love the most use only very simple collision detection. I know it’s a very basic game, and it was mostly an exercise in solving the basic problems involved in creating an arcade style game, but I still found myself actually enjoying testing it :) So if you have nothing better to do, go ahead and play…no quarters required. **note – it has sound effects.. so you may want to grab some headphones to avoid annoying others.

space invaders

Since Adobe announced the launch of Flash Player 10.1, targeting mobile devices, I’ve been thinking that such small casual games such as these are a good fit for mobile devices, so I will release mobile-sized versions, once I have a better idea of the target platforms’ screen sizes. Then if any of the games get popular, I could also release a standalone AIR version for a small price for the serious addicts, which would provide better performance, screen size, and probably a few bonus features. Since I do have some commercial ambitions here, I’m not releasing the final game code in full, but I hope to document some of the more interesting techniques I’ve used, such as the dissolve effect, in later posts.