Monday 27 April 2009

Adventures in 3D: Part X - On the move

Yay, so we've finally got to the point where we can move a camera around in our scene, thanks to matrices. In the last post, we used a TransformMatrix to convert the world z-coordinate to something relative to the camera position, albeit with a hard coded view point. Now we just need to start hooking that into something that allows us to change that viewpoint. The ThreeDeePanel class already contains a worldToCamera variable that stores the transform matrix representing the camera view. Let's do things properly now - it's not hard to imagine that a camera is going to have a few different properties, so let's create a Camera class. Let's also represent the position as a Point instead of the raw transform matrix.

public class Camera {

private Point position;

public Camera(Point p) {
position = p;
}

public Point getPosition() {
return position;
}
}

Then we keep an instance of the Camera in the ThreeDeePanel class, and provide a method for other classes to get at it. We also need to calculate the corresponding Matrix from the camera's viewpoint before calling project():

Camera camera = new Camera(new Point(0,0,-300));

public void getCamera(Point p) {
return camera;
}

protected void paintComponent(Graphics g) {
...
TransformMatrix worldToCameraTransform = TransformMatrix.getWorldToCamera(camera.getPosition());
for(Primitive poly : renderScene){
poly.project(worldToCameraTransform);
poly.draw(g2);
}
...
}

In the ThreeDee class, which is doing all the input, I'm going to listen out for the cursor keys (assuming we're in the MOVE_CAMERA state), grab the camera and change it's position:

private int CAMERA_SPEED = 3;

public ThreeDee() {
...
panel.addKeyListener(new KeyAdapter() {
public void keyPressed(KeyEvent e) {
....
} else if (moveState == MoveState.MOVE_CAMERA) {
boolean ctrlDown = ((e.getModifiersEx() & MouseEvent.CTRL_DOWN_MASK) == MouseEvent.CTRL_DOWN_MASK);
Camera camera = panel.getCamera();
switch(e.getKeyCode()) {
case(KeyEvent.VK_UP):
if(ctrlDown) {
camera.getPosition().y += CAMERA_SPEED;
} else {
camera.getPosition().z += CAMERA_SPEED;
}
break;
case(KeyEvent.VK_DOWN):
if(ctrlDown) {
camera.getPosition().y -= CAMERA_SPEED;
} else {
camera.getPosition().z -= CAMERA_SPEED;
}
break;
case(KeyEvent.VK_RIGHT):
camera.getPosition().x += CAMERA_SPEED;
break;
case(KeyEvent.VK_LEFT):
camera.getPosition().x -= CAMERA_SPEED;
break;
}
}

Give that a go, and you should have a moving camera! However, move around a bit and you'll probably notice a problem. The canBeCulled() method in the Triangle class is still using a hard coded viewpoint to decide whether a face is away from the viewer, so if you move up alongside the object, you'll see that it's rear end is missing. We just need to adjust that method to take the new viewpoint into account (again, don't forget to change the method signature in the Primitive interface):

public boolean canBeCulled(Point camera) {
if (WIREFRAME) return false;

Vector viewer = camera.vectorTo(getPosition());
double cull = normal.dotProduct(viewer);
return (cull > 0);
}



As funky as this is, there is the small problem of only being able to look straight ahead. With the point where the camera is, we also need to store some information about which way it's pointing. The camera ought to be free to rotate around any of the axes - look left and right, up and down, and roll side to side. These are commonly know as yaw, pitch and roll. The good news is that these are simply rotations, which we already know how to deal with. What's slightly different is that we're no longer rotating around the world origin, but instead treating the camera as the origin. Also, going right back to the discussion in Part I, the rotation of the objects in the world is opposite to the camera rotation - turning the camera 90 degrees right is like rotating the world 90 degrees left.

We'll store the rotation of the camera as three angles, alpha, beta and gamma, which represent the rotation around the X, Y and Z axes respectively. It's worth considering exactly what that means, to save confusion later. Rotation around the X axis is the looking up and down (pitch) - it's easy to see the "X" and immediately think it ought to be side-to-side motion. Likewise, rotation around Y is looking side-to-side (yaw), and around Z is roll. I'll add a rotate() method to the Camera to change those angles:

public void rotate(double da, double db, double dg) {
alpha += da;
beta += db;
gamma += dg;
}

Now we just need to be able to get the appropriate RotationMatrix that represents those angles. Given rotation matrices Rx, Ry and Rz for each of the three axes, the total rotation is simply Rz.(Ry.Rx). Note that we multiply Rx and Ry first, then multiply by Rz. Why? Because applying rotations in different orders gives different results. Imagine you're looking at a point straight ahead. First, look up 45 degrees. Then look right 45 degrees. Then roll over 90 degrees. You're now looking at a point "top right" of where you were originally, and with your head tilted to one side. Start again, but this time do the "roll" first. You'll actually end up (if you've done it properly...) looking at a point "top left" of where you were. The secret is in the fact that "up" and "right" are relative to which way you're already facing. If you're on your feet and tilt your head backwards to look up, it really is "up". If you were lying on your side on the ground and tilted your head back, you'd actually still be looking along the ground. You would actually have to look "right" to look "up".

So, I'll add a new method to RotationMatrix to get an instance that represents alpha,beta,gamma, which we'll do by getting instances for each axis and multiplying them together.

public static RotationMatrix getInstance(double alpha, double beta, double gamma) {
return RotationMatrix.getInstance(gamma, RotationAxis.Z).times
(RotationMatrix.getInstance(beta, RotationAxis.Y).times
(RotationMatrix.getInstance(alpha, RotationAxis.X)));
}

Finally, we implement a getCameraRotation() method on Camera to get that matrix, and multiply it by the position transform matrix we created above to give the final transform from world to camera. Note that the method signature for project changes to take a base Matrix type. Also, we need to change our RotationMatrix class (and any code that calls it) to use homogenous coordinates, so that we can multiply a RotationMatrix and TransformMatrix. We know that this is pretty simple, just add a row and column to each matrix, with a 1 in the bottom right corner (in hindsight, there's no need for RotationMatrix and TransformMatrix to be different types, really we should just use the base Matrix class. Ah well, you live and learn)

public RotationMatrix getCameraTransform() {
Matrix translation = TransformMatrix.getWorldToCamera(position);
return RotationMatrix.getInstance(alpha, beta, gamma).times(translation);
}

and in ThreeDeePanel:
Matrix worldToCameraTransform = camera.getCameraTransform();

This is all very well, but it doesn't actually do much yet. All we have to do though, is wire it up to the input listeners. Whilst in the MOVE_CAMERA state, dragging the mouse will look left,right,up,down, and moving the scroll wheel will roll.

public void mouseDragged(MouseEvent e) {
...
case MOVE_CAMERA:
panel.getCamera().rotate(dy * RADIAN,dx * RADIAN,0.0);
}
}

and the same sort of thing in mouseWheelMoved() for the roll. Note that dx (moving the mouse left and right) affects the Y axis rotation, and vice versa, for the reasons we discussed above.

Run that, and you too can look around the back of your object! Take care to check whether the rotation is what you expect, as it's not always obvious. Matrix multiplication is not commutative - AB is not the same as BA - and if you've got something in the wrong order, it'll generally manifest itself here as the axis of rotation being wrong. For instance, you may find that instead of the object rotating around the camera, the camera rotates around the object.



You may also notice that the scene "stretches" when it's rotated towards the edge of the screen. This is related to the focal length. If the effect looks unnatural, try increasing the focal length - a shorter focal length effectively gives you more of a "fish-eye" look.

Now you've managed to get around the back, you've probably also noticed a few problems with the z-order. Remember that we're sorting, and therefore drawing, the screen by (world) z position, which is fine as long as we're looking in the +ve z direction. As soon as we move elsewhere, that becomes useless - we now need to sort by z order relative to the camera. That means we need to be converting from world space to camera space before doing the sorting.

Let's also take the opportunity to tidy up the Triangle class. We're currently marshalling vertex data between arrays and matrices, whereas we could just keep the vertex data as a matrix and be done with it. For convenience, I'll add methods to the Vector and Point classes to convert to/from matrices.

As well as storing the world coordinates, we'll also keep a matrix of the view coordinates in Triangle. These will be populated in the project() method, which now needs to move in the pipeline to a point before we do the z-order sorting.

public class Triangle extends Primitive {
// vertices of the triangle in world space
Matrix[] vertices = new Matrix[3];
Matrix[] viewVertices = new Matrix[3];

public void project(Matrix worldToCamera) {

for (int i = 0; i < 3; i++) {
viewVertices[i] = persMatrix.times(worldToCamera.times(vertices[i]));

if(viewVertices[i].data[2][0] < 0) {
draw = false;
return;
}

xPoints[i] = (int) (viewVertices[i].data[0][0] / viewVertices[i].data[3][0]);
yPoints[i] = (int) (viewVertices[i].data[1][0] / viewVertices[i].data[3][0]);
}
}
}

The doPipeline() method now consists of backfaceCulling(), project(), sortForZOrder(), lightScene(). All we need to do now is change getZOrder() in Triangle to return the z order from the (view coords) viewVertices array instead of the (world coords) vertices array.

public double getZOrder() {
return getAverage(viewVertices, 2);
}

@Override
public Point getPosition() {
return new Point(getAverage(vertices, 0), getAverage(vertices, 1), getAverage(vertices, 2));
}

private double getAverage(Matrix[] matrices, int index){
return (matrices[0].data[index][0] + matrices[1].data[index][0] + matrices[2].data[index][0])/3;
}

There, all sorted. We're gradually getting there. Unfortunately, it seems that for every bit we add, it throws up another couple of problems to solve. But we love solving problems, right? Right?! Put the kettle on, make a cup of tea, download the source, and let's ponder.

No comments: