Building An Isometric Game Using Horde3D (Part 2)
The Isometric Camera
The first order of business is to dig into the Horde3D camera and get it set up for isometric rendering. It turns out to be pretty easy to do in Horde3D. To simplify, we'll create a class to encapsulate the camera so that we don't always have to muck with the internals:
-- Isometric camera IsometricCamera=class(function(self, name, pipeline, nodescreensize) self.cam=h3dAddCameraNode(H3DRootNode, name, pipeline) h3dSetNodeParamI( self.cam, H3DCamera.ViewportXI, 0 ) h3dSetNodeParamI( self.cam, H3DCamera.ViewportYI, 0 ) h3dSetNodeParamI( self.cam, H3DCamera.ViewportWidthI, config.screenwidth ) h3dSetNodeParamI( self.cam, H3DCamera.ViewportHeightI, config.screenheight ) h3dSetNodeParamI( self.cam, H3DCamera.OrthoI, 1) local screennodes_x = config.screenwidth / nodescreensize local screennodes_y = config.screenheight / nodescreensize h3dSetNodeParamF( self.cam, H3DCamera.LeftPlaneF, 0, -screennodes_x/2) h3dSetNodeParamF( self.cam, H3DCamera.RightPlaneF, 0, screennodes_x/2) h3dSetNodeParamF( self.cam, H3DCamera.BottomPlaneF, 0, -screennodes_y/2) h3dSetNodeParamF( self.cam, H3DCamera.TopPlaneF, 0, screennodes_y/2) h3dSetNodeParamF( self.cam, H3DCamera.NearPlaneF, 0, -30.0) h3dSetNodeParamF( self.cam, H3DCamera.FarPlaneF, 0, 30.0) end) function IsometricCamera:SetPosition(x,z) h3dSetNodeTransform( self.cam, x, 0, z, -30.0 ,45, 0, 1, 1, 1 ) end
This is a very basic encapsulation of a camera into a Lua class. (Again, using the "class" template described at http://lua-users.org/wiki/SimpleLuaClasses ) When the class is instanced, a basic orthographic-projection camera is set up. The left, top, right and bottom frustum planes are calculated as a function of the screen dimensions divided by the screen-size of a single map cell. In world coordinates, one map cell is sized 1x1 in X and Z, with Y (vertical) being arbitrary. However, the on-screen size of a cell is dependent upon the size of the assets. In the case of Goblinson Crusoe, a sprite such as the mountain shown earlier, intended to occupy one entire cell, is 256 pixels in width. So the frustum width is calculated as ScreenWidth / SpriteWidth, and correspondingly the frustum height is calculated as ScreenHeight / SpriteWidth. Thus, given an 800 x 600 screen, the above camera, assuming the 256-pixel width of GC assets, would show a view 3.125x2.34375 nodes in size. This can be a little confusing, but the gist of it is that the frustum planes need to be set so that one cell will be drawn the correct size, pixel-for-pixel. If the wrong sizes are used, then the on-screen image may be scaled up or down, possibly resulting in artifacts.
The camera initialization also sets the near and far planes. You might notice the somewhat odd setting of the near plane as negative. Typical applications of a 3D camera with perspective will set the near plane to a very small positive value, ensuring that the whole of the frustum lies entirely in front of the camera. This fits the abstraction of a perspective camera being analogous to a real-life camera taking pictures of objects in front. However, in our case, the camera itself basically occupies a 2D plane. The SetPosition method allows us to alter the x and z coordinates, but y is essentially locked, in true isometric fashion. In order to simplify the math, the camera is placed at Y=0. Thus, when SetPosition(0,0) is called, then the point (0,0,0) will lie directly in the center of the screen. Correspondingly, if SetPosition(8,8) is called, the point (8,0,8) is in the center of the screen. Effectively, calling SetPosition sets the center, or the look-at point, of the camera. However, if the near plane is set to a positive number, as is the case with a "standard" camera, then effectively half the scene would be cut off. Setting Near to a negative value enables more of the scene lying "behind" the camera to be shown. I chose arbitrary values of -30/+30 for near and far, which are "good enough." More exact values could easily be calculated.
Now, when I initialize the scene I instance a camera. Then, during the render loop, I use the cam member of the instanced camera as the camera to render with. During object update stages, I can have an object with the CameraControlComponent call SetPosition() on the current camera with the object's position, ensuring that the map view will follow that object. This type of interface will correspond directly to the existing functionality in Goblinson Crusoe for setting the map view center.
Before I tackle the ickiness that is to come (ie, getting the existing Goblinson Crusoe assets to work in the new engine) I want to test the camera out. So I fire up Blender and I create a plain gray cube, sized 1x1x1. I scatter a few of these around for effect, and specify 64 pixels for the on-screen size of a cell when instancing the isometric camera.
Looks good. Note from the screenshot, and from the SetPosition method of IsometricCamera, that this isn't a true isometric projection, but is in actuality the dimetric projection that results in a 2:1 tile ratio, as seen in games like Diablo. I like this projection, because it is easy to work with. And, of course, all of the assets for Goblinson Crusoe are rendered in this projection, so if I want to reuse them, I have to go with the same view.
Up next comes the difficult part. I need to figure out a rendering scheme that will render the scene, given that all of the pre-rendered assets (ground decals, objects, etc...) are rendered with anti-aliased, soft edges as described before. I need to figure out the shaders and lighting I'll need. This part might be a bit tricky, since I won't be using what you might call "standard" 3D lighting. Goblinson Crusoe implements a couple of things (fog of war, visibility-based lighting, negative lighting, and so forth) that seem like they'd be tricky to implement using "standard" models.