How to fully fit an Away3D plane in the viewport
Posted on 2012-02-06
There has been a lot of people who played with Away3D and built awesome things. A lot of said awesome things were built by people who know, or know someone that knows 3D modeling, which is why the quality has been so awesome. However, it may not always be necessary to build a full 3D world, sometimes a project may simply require some 3D elements over a 2D background. But how do you place an image behind all sorts of 3D objects if Away3D’s
View3D has a black background? I will try and explain the process in this article. Warning: matrices and trigonometry ahead!
Build it and they will come
Usually, if you want to place an image and make it fit your document dimensions, you simply stretch the image accordingly. In 3D however, it is not as simple, as the world dimensions are not necessarily measured in pixels. While working with the guys from Float4, I came to understand that meters are oftentimes used as measurement unit. With that in mind, we cannot directly use the dimensions of our document.
So let’s say we want to place an image in the 3D world and make it fill the viewport (the visual space taken by Away3D). At first, we would need an image, and a plane onto which we would like to place the image. As I wrote previously, materials applied onto shapes need to be in dimensions that are a power of 2, so will will need to rescale our image. A
Matrix will fulfill this need.
private const WIDTH:uint = 640; private const HEIGHT:uint = 640; private const MATERIAL_WIDTH:uint = 1024; private const MATERIAL_HEIGHT:uint = 1024; [Embed(source="assets/images/escher-relativity.png")] private const EscherImageClass:Class; private var _imageData:BitmapData; private var _matrix:Matrix; private var _material:BitmapMaterial; private var _plane:Plane; private var _view3D:View3D;
HEIGHT represent the dimensions of my document, and also the dimensions of the image. The
MATERIAL_HEIGHT represent the dimensions needed for the material, dimensions that are a power of two. Here I will be using an image from Escher, obtained from Wikipedia. There is no need for a
Bitmap per se, but we will need the image’s
BitmapData, hence the
Let’s do this!
Normally, I resize the material dimensions to a value higher than what I need to display; first the material is stretched up, and then visually fitted to the original dimensions of the image. If we were to scale the material to smaller dimensions than that of the image, the plane would stretch up the material afterwards when displaying the image, resulting in possible loss of smoothness or detail.
_matrix = new Matrix(); _matrix.scale(MATERIAL_WIDTH / WIDTH, MATERIAL_HEIGHT / HEIGHT);
Let’s draw an instance of the Escher image into the
_imageData. This is where
_matrix comes in handy. I wonder what pushed Adobe’s engineers to make an architectural choice where they thought users would use a
BlendMode or a clipping
Rectangle more often than a flag to smooth the drawing. I mean, putting the flag as a last argument? Anywho, let’s add a lot of
_imageData = new BitmapData(MATERIAL_WIDTH, MATERIAL_HEIGHT); _imageData.draw(new EscherImageClass(), _matrix, null, null, null, true);
Next, create the material. As seen previously, let’s use a simple trick to assign whether or not the material is mipmapped.
_material = new BitmapMaterial(_imageData, true); _material.mipmap = (_imageData.width == _imageData.height);
Apply the material to the plane, add the plane to the
View3D, and then add the view to the display list.
_plane = new Plane(_material); _view3D = new View3D(); _view3D.scene.addChild(_plane); addChild(_view3D);
Do not forget to listen to
Event.ENTER_FRAME and call the
View3D.render() method at every update.
Run it, and what do we get?…
By default, the plane is added at (0, 0, 0), so it should be visible…
It is, it turns out that an API choice—which may make sense in a 3D world, but that I do not understand—puts the plane with its face up on instantiation. What this means is when it is created, we see its side, which has no thickness. Just change the
yUp parameter to
false, so the new plane will then face the camera upon creation.
_plane.yUp = false;
We should get something like this.
If we compare the image and this result, we notice that the image’s edges are cropped, which means the plane is too close. Below is a diagram explaining this visually.
Imagine a pyramid drawn from the camera at its top with an angle that is the camera’s field of view, up until a certain point. From the camera’s point of view, there is always a certain frame that is visible, that is the viewport. However, that pyramid is not infinite, actually what the camera sees is not even a pyramid. The viewing frustum, what is visible within a 3D world, is an area from a certain threshold (the near plane) to another one (the far plane), that’s what is visible within a 3D world. What happens with the code above is that the plane is bigger than the frustum, thus being cut.
Below is what we want to achieve:
This is where the fun begins! How do we fit our plane into our viewport? It is possible to simply move the plane on the z axis and eventually position the plane properly to fit our need. But what if the dimensions change? We’d be back onto square one. This problem requires good ol’ trigonometry, and the pythagorean theorem.
You may wonder what this title means. It’s a mnemonic trick that’s been given to me back in high school on how to remember the rules of trigonometry:
Sine of an angle = Opposite side / Hypothenuse
Cosine of an angle = Adjacent side / Hypothenuse
Tangent of an angle = Opposite side / Adjacent side
Then let’s consider this as a trigonometry problem, illustrated below. We have a camera, the
Camera3D, which is located at (0, 0, -500) by default. Look at the sources to confirm, it is the case with Away3D, but it may be different in other 3D engines. We have a plane with a position yet undefined, but we do want to make sure that its fits perfectly into the viewport.
Where does that 60° angle in the illustration above come from? If we take a look at the
Camera3D constructor, we find that it uses a
PerspectiveLens by default. In that lens constructor, we can see the default field of view is 60, which is in degrees. It’s too bad we need to look at the source to obtain this, it would have been simpler to use a getter to be able to check it, but there is none.
Good, we have an angle, we can calculate dimensions and positions. Let’s set an arbitrary z for the plane, we’ll see further that it doesn’t matter.
var angleY:Number = 60; var cameraZ:int = _view3D.camera.z; var planeZ:int = 500; var distFromCamToPlane:Number = Math.abs(cameraZ) + planeZ;
Since the field of view is vertical, let’s measure the height of our plane first. Let’s not forget that the field of view covers the whole height, but in order to work with rectangle triangles, we will use only half of that angle. We know the angle, we know the distance from the camera to the plane (the adjacent side), but we do not know the opposite side, so let’s use the tangent. The
Math.tan() takes radians, so just make sure to convert those degrees to radians.
var planeHeight:int = Math.tan((Math.PI / 180) * (angleY * 0.5)) * distFromCamToPlane;
Since this represents only half of the height, let’s correct it for what we need further.
planeHeight *= 2;
To obtain the width of our plane, simply apply the aspect ratio of the image. In this case the image is a square, but it may very well be any kind of rectangle.
var aspectRatio:Number = WIDTH / HEIGHT; var planeWidth:int = planeHeight * aspectRatio;
And now we are ready to create our plane with the proper dimensions and position.
_plane = new Plane(_material, planeWidth, planeHeight); _plane.yUp = false; _plane.z = planeZ;
Let’s take a look at the whole process at once.
// get the scale to apply to the image _matrix = new Matrix(); _matrix.scale(MATERIAL_WIDTH / WIDTH, MATERIAL_HEIGHT / HEIGHT); // create the image data _imageData = new BitmapData(MATERIAL_WIDTH, MATERIAL_HEIGHT); _imageData.draw(new EscherImageClass(), _matrix, null, null, null, true); // create the material _material = new BitmapMaterial(_imageData, true); _material.mipmap = (_imageData.width == _imageData.height); // create the view _view3D = new View3D(); // camera values // see away3d.camerasCamera3D's default lens' field of view // in away3d.cameras.lenses.PerspectiveLens // it turns out that the field of view is vertical var angleY:Number = 60; var cameraZ:int = _view3D.camera.z; // this position is arbitrary as we will see further // useful to calculate the distance between the camera and the plane var planeZ:int = 500; var distFromCamToPlane:Number = Math.abs(cameraZ) + planeZ; // since the field of view is a vertical angle, calculate the height of the plane first // use pythagorean theorem to calculate // tip for trigonometry: soh-cah-toa // toa: tan(angle) = oppositeSide / adjacentSide var planeHeight:int = Math.tan((Math.PI / 180) * (angleY * 0.5)) * distFromCamToPlane; // and since it was for a rectangle triangle, thus half the size, double the length planeHeight *= 2; // useful for resizing the image var aspectRatio:Number = WIDTH / HEIGHT; // use the aspect ratio to calulcate the width of the plane var planeWidth:int = planeHeight * aspectRatio; // create the plane _plane = new Plane(_material, planeWidth, planeHeight); _plane.yUp = false; _plane.z = planeZ; // add 3D objects to the scene _view3D.scene.addChild(_plane); // add the away3d view to the display list addChild(_view3D); // listen to the updates addEventListener(Event.ENTER_FRAME, updateHandler);
This should produce a result like this, where the image fits perfectly in the viewport.
For additional fun, I added an option to click to rotate it, so that it demonstrates that it is indeed 3D.
As I always do in my tutorials, you can download the sources. This project has been built for Flash Player 11+ with FDT. You may need to adapt the Flex SDK.