Building An Isometric Game Using Horde3D (Part 1)

Published July 29, 2011
Advertisement
I recently wrote a post about using "pure" SFML and traditional 2D methods to create an isometric game. I also indicated in the same post how, in my opinion, traditional 2D isometric games are pretty much dead, at least on most platforms. I'm sure there is a platform, somewhere, that a 2D isometric engine might be more appropriate, but it's certainly not a platform that I would ever develop for if so. To my mind, the advantages offered by switching to a full 3D engine are irreplaceable. Even if you still structure the game to look like an old school 2D game, 3D still has benefits.

Some time back I had posted about switching the underlying framework of Goblinson Crusoe to 3D, and in another post I had mentioned stumbling upon the latest release of Horde3D, which seemed like it might suit. It's small, relatively lightweight, and most of all extremely easy to bind to Lua, given that Lua is my preferred language for game logic these days. So, for science, I have decided to evaluate Horde3D and determine its suitability for use in an isometric game, and to post a running progress update as I go.

I'm writing this without any extensive experience with Horde3D. I've written the binding code to Lua, gotten the samples in the sample directory to work as Lua programs, and that's pretty much it. This evaluation will include: ease of code integration, ease of asset creation, and performance. Performance is key, for though I create relatively low-detail isometric games, I do so on a crappy laptop from 2005 or so that was middle-low end at the time that it was new, and is now a veritable dinosaur.

This last part is actually a potential sticking point for me, in that Horde3D is designed as a so-called "next gen" graphics engine that relies on a purely shader-based architexture. It requires at minimum an OpenGL 2.0 compliant card. My card is just barely sufficient; indeed, a number of the shader contexts in the materials provided with the samples will not link on my machine due to lack of feature support in my hardware. The forward-rendering "traditional" pipeline stuff works, but deferred or HDR pipeline stuff tends not to work so much. However, if I stick with the simpler shader effects, I'm okay, and it should be fine to do an isometric with.


The Framework Base


As with all projects, there is a certain amount of boiler-plate I have to get out of the way first. This involves bringing in the main libraries I'll be using, as well as the binding code to expose those libraries, or at least the relevant parts of those libraries, to Lua. That was what I've been doing the last couple days. The experiment is going to use SFML for windowing, input and system functions such as sf::Clock, and drawing the interface. It is going to use Horde3D for all the rest. In the last several weeks, I've been exploring the new SFML 2.0 builds. Except for one or two minor snags which I mentioned in a rant post a few weeks ago, SFML 2.0 has been an absolute breeze to bind to Lua. I use toLua++ for all of my Lua-binding needs, and generating the required header package files to give to toLua++ for SFML and Horde3D both has been a simple matter. As with SFML, there were a couple minor snags with Horde3D; notably, places where Horde3D does something weird like mapping a resource buffer to a void pointer and so forth. I skipped those places and if using them becomes necessary in the future, I can easily enough write some intermediate code to do what I need without having to figure out how to map a void pointer to anything meaningful in Lua.

So my first work was to write bindings to the most current builds of Horde3D and SFML that I have downloaded: a recent snapshot of SFML, and the Beta 5 release of Horde3D. Then I had to get a project up and running. This was purely a matter of combing through the library headers and copy-pasting the required systems to bind. In many cases, I could just copy/paste entire header files, due to the cleanness of the includes in both libraries. It really only took me a matter of a couple hours to get workable Lua binds to both libraries bound and usable.

For the executable, I follow a standard format that I have used in many of my projects, including Goblinson Crusoe. I copy/paste the main.cpp file from the standard Lua interpreter included in the Lua 5.1 source distribution, and make some slight modifications which include calling the various tolua...open functions generated by toLua++ to expose the functionality of the libraries to a given Lua state. Then I compile based on the value of a #define called DEFAULT_STARTUP_SCRIPT which, if set, provides the path and filename of a script to execute by default on startup. If this is not set, the executable will go to a Lua command-line prompt instead.

At this point, I'm good. I've exposed the relevant parts of SFML and Horde3D to Lua, I've got a working interpreter environment, I've even loaded the bindings for my noise library for some random generation down the road. So it's time to start writing Lua code to see exactly how well this setup will work. My goal is to emulate as closely as possible the interfaces and components already existing in the Goblinson Crusoe project. Many of the existing components will work as-is. Others will require rewrites. I'll start as I did with GC, by getting an interactive random map demo up and running.

Before I worry about getting anything cool on the screen, I want to get my initialization routine and my execution kernel out of the way. In these areas, I also have a preferred format of doing things.

For configuration, to start with I want to be able to specify a configuration file that holds the basic parameters for the system: screen resolution, fullscreen/windowed mode, etc... Luckily, working in Lua makes these things extremely easy. I use the code for serializing tables that can be found at http://lua-users.org/wiki/SaveTableToFile which will save tables to a file that is human-readable and human-editable:

[source]
-- config.cfg

return {
-- Table: {1}
{
["screenheight"]=600,
["screenwidth"]=800,
["windowed"]=true,
["configversion"]=1,
},
}
[/source]

I like to add at least a little bit of robustness in my configuration system by providing a function to generate a default config file. I call the function, loadConfig, that will attempt to load the configuration file and match the field of configversion against a value set in-game. If the config file doesn't load, or if the versions don't match, then it will generate a new config file with default values and save that configuration. Otherwise, it uses the values loaded from the file. This configuration can be edited in-game, and the changes saved out to config.cfg as needed, for things like changing the resolution, setting windowed mode, etc... If the version changes (for instance, if new fields are added) then a new config file can be generated to replace the old out-of-date one.

So with configuration in place, I can frame out the basics of program startup and shutdown. To startup, I need to initialize SFML and Horde3D using the configuration parameters. Shutdown is just cleaning up those two systems. As a good starting point for all of this I have the following code, including the config file stuff:

[source]
-- startup.lua

-- Append scripts directory to package search path
package.path=package.path..";.\\scripts\\?.lua"

-- Requires
require 'tablesaveload'

configversion=1 -- Current version of the config file

------------------------------------------------------------------------
-- Startup/Init functions

-- Load/Restore configuration
function loadConfig(configname)
local config=table.load(configname)
if config==nil or config.configversion~=configversion then
config=generateDefaultConfig()
saveConfig(config, configname)
end
return config
end

-- Save configuration
function saveConfig(config, configname)
if config==nil then
-- generate default configs
config=generateDefaultConfig()
end

table.save(config, configname)
end

-- Generate default config file
function generateDefaultConfig()
local c=
{
screenwidth=800,
screenheight=600,
windowed=true,
configversion=1
}

return c
end


-- Create window from config
function createWindowConfig(config)
if config==nil then
print("Error: Configuration parameters not loaded.")
return false
end

local style=sf.Style.Close
if config.windowed==false then style=style+sf.Style.Fullscreen end

local window=sf.RenderWindow(sf.VideoMode(config.screenwidth, config.screenheight), "Goblin Wizard", style)
window:SaveGLStates()
if (h3dInit()==false) then
print("Error: Couldn't init h3d")
h3dutDumpMessages()
window:Close()
return false
end

return true,window
end

-- Shutdown/Cleanup

function shutdown()
h3dutDumpMessages()
h3dRelease()
if window then window:Close() end
end



------------------------------------------------------------------------
-- Startup and init procedure

config=loadConfig("config.cfg",1)

success,window=createWindowConfig(config)
if success==false then
print("Error: Could not init.")
return
end



shutdown()
[/source]

If executed, the above code will load the config file or generate one if the file is non-existent or invalid. It will create a window, initialize Horde3D, then shutdown the system and exit. Not very impressive, but the framework is there. One thing of note here is the presence of the call to window:SaveGLStates() just prior to the call to h3dInit. This is important, since we are going to use SFML to draw the UI stuff. Initializing Horde3D stomps the hell out of our OpenGL state, so in order to get back to the state required by SFML we need to save that state. Then, in the draw loop, we need to draw everything in Horde3D, then call window:RestoreGLStates, perform any SFML rendering, and again call window:SaveGLStates in preparation for the next time through the draw loop when Horde3D will again stomp the snot out of everything.

Next up: tackling the main loop...

[page]
The Execution Kernel
The next step is to get the execution kernel up and running. The execution kernel (typically called the game loop) is where the magic happens. For my games, I tend to prefer a fixed-timestep system with visual interpolation for smooth framerates, as originally described at http://www.flipcode.com/archives/Main_Loop_with_Fixed_Time_Steps.shtml

In my own adaptation, I encapsulate it into a "class" in Lua. This encapsulation is another convention that I like to use, detailed at http://lua-users.org/wiki/SimpleLuaClasses which implements a simple class framework. This is useful for defining the behavior and structure of a Lua table in one place, and instancing a table of that type in another place.

I encapsulate the above-mentioned fixed-timestep loop into a class, CoreKernel, which acts as the beating heart of the game. Lying in between startup and shutdown, this kernel loop is where everything in the game happens. Input is processed, things are drawn, logic is ticked, and so forth. In order to control the states of the game, CoreKernel accepts and operates on a Lua class or table called a statecontext, which implements the functions render, addFrameTime, updateLogic, and handleEvent. Different statecontexts can perform these various tasks differently. The loop kernel looks like:

[source]
-- kernel.lua

require 'class'

CoreKernel=class(function(a)
a.reset_loop=false
a.running=false
a.oldtime=0
a.logicupdatecounter=0
a.logicstep=0
a.curtime=0
a.framedelta=0
a.percentwithintick=0
a.statecontext=nil

end)

function CoreKernel:addFrameTime(elapsed_time, percent_within_tick)
if self.statecontext~=nil then
self.statecontext:addFrameTime(elapsed_time, percent_within_tick)
end
end

function CoreKernel:updateLogic()
if self.statecontext~=nil then
self.statecontext:updateLogic()
end
end

function CoreKernel:handleEvent(event)
if self.statecontext~=nil then
self.statecontext:handleEvent(event)
end
end

function CoreKernel:render()
if self.statecontext~=nil then
self.statecontext:render()
end
end

function CoreKernel:endLoop()
self.running=false
end

function CoreKernel:resetLoop()
self.reset_loop=true
end

function CoreKernel:run(updates_per_sec, startingstate)
self.statecontext=startingstate
if self.statecontext==nil then
print("Invalid starting state; exiting kernel.")
return
end

self.clock=sf.Clock()

self.reset_loop=false
self.running=true
self.oldtime=self.clock:GetElapsedTime()/1000.0
self.logicupdatecounter=0
self.logicstep=1.0/updates_per_sec
self.purgeevents=false
event=sf.Event()

local thistime=0
local timesincelastshow=0
local framecount=0
local totaltime=0

while(self.running) do
if(self.reset_loop) then
self.oldtime=self.clock:GetElapsedTime()/1000.0
self.logicupdatecounter=0
self.reset_loop=false;
self.purgeevents=true
end

-- Process input
while (window:PollEvent(event)) do
if self.purgeevents==false then self:handleEvent(event) end
if self.running==false then return end
end
self.purgeevents=false

self.curtime=self.clock:GetElapsedTime()/1000.0
self.framedelta=(self.curtime-self.oldtime)
totaltime=totaltime+self.framedelta
thistime=thistime+self.framedelta

-- Update
self.logicupdatecounter = self.logicupdatecounter+self.framedelta
while(self.logicupdatecounter >= self.logicstep) do
-- Update logic
self:updateLogic()
self.logicupdatecounter = self.logicupdatecounter - self.logicstep

if self.reset_loop then self.logicupdatecounter=0 end
end

-- Render

self.percentwithintick = self.logicupdatecounter / self.logicstep
self:addFrameTime(self.framedelta, self.percentwithintick)

self:render()


self.oldtime=self.curtime
framecount=framecount+1
end
end

[/source]

The various aspects handled by the statecontext are:

render(): Draw the view.
handleEvent(event): Handle an input event (SFML event structure)
updateLogic(): Update game logic
addFrameTime(elapsed,percent): Advance animations, decrement timers, interpolate visual positions for smooth movement, etc... Passes a value for elapsed time (ie, how much time has elapsed since the last time through the loop) and for percent time which is a value in the range of (0,1) representing how far into the next logical time-step we are. This value is used for interpolating transformations of visible objects to ensure a smooth visual framerate independent of the logical framerate.

The kernel provides ways for kernel execution to halt (by setting kernel.running to false) and for the kernel loop to be "reset". A reset ability is required because the kernel acts to update the game at a fixed number of updates per second, regardless of framerate. This means that if it takes a long time for something in the loop to complete, then the loop may execute a large number of updates in sequence in order to catch-up, before another frame is drawn. Thus, if something like a level load takes a long time, this it would mean that everything in the newly-loaded level would receive a large number of logic updates before the player could do anything. (This is the sort of behavior you would see in the first Diablo game where you would go down the stairs, see a loading screen, then see a half-second of frantic motion on-screen as the Azure Drakes pounded your poor, soft, squishy little body into so much raspberry paste on the dungeon floor, scattering all your hard-won loot to the winds; cripes, I hated that.) This is not good. So the core kernel provides a flag you can set, resetloop, which will reset the timers controlling the loop the next time through the loop, and purge any waiting input events, to prevent there being that spurt of activity that gets you killed at level load.

To start with, I'm going to build a basic skeleton of a statecontext upon which to expand. For now it won't do anything except clear the screen and flip buffers, and handle a sf.Event.Closed message to kill the kernel and proceed to shutdown:

[source]

-- BasicContext
BasicContext=class(function(self)

end)

function BasicContext:addFrameTime(elapsed, percent)
end

function BasicContext:updateLogic()
end

function BasicContext:handleEvent(event)
if event.Type==sf.Event.Closed then
kernel.running=false
end
end

function BasicContext:render()
window:Clear()

-- Draw h3d

window:RestoreGLStates()

-- Draw UI

window:SaveGLStates()
window:Display()
end

[/source]

With that skeletal context, the startup and shutdown process looks like this:

[source]
-- Startup and init procedure

config=loadConfig("config.cfg")

success,window=createWindowConfig(config)
if success==false then
print("Error: Could not init.")
return
end

testcontext=BasicContext()

kernel=CoreKernel()
kernel:run(25, testcontext)

shutdown()

[/source]

Of course, in a "real" game I would move the state context class into its own file, in order to implement various different states, and keep them from cluttering the startup/shutdown section. For now, though, this is fine. If I execute this now, I get to stare at a black 800x600 window which will close when the Close button is clicked. The skeleton of the render section shows the details of the GL state switching necessary to make Horde3D and SFML play nice together.

Next up is to get something visible on the screen from both Horde3D and SFML, just to make sure things are all working and kosher, and that things aren't stepping on each others' toes. I could go the "easy" route and just grab one of Horde3D's sample assets, but that wouldn't really teach me a whole lot about the process. I'll reuse the sample forward-rendering pipeline and the model shader, since they're setup pretty much the way I would want them to be for now, but I'm going to build a model in Blender and work it through the pipeline process.

[page]
Building and Importing a Static Mesh into Horde3D

I fired up Blender. I've read that the Google Summer of Code team working on Collada export has disabled animation export temporarily in the latest Blender, 2.58, so that could prove to be a problem. Of course, judging by the tone of forum posts, Blender Collada export in general has been somewhat of a thorny issue. A workaround for animated models seems to be exporting to .FBX format, then running a tool from Adobe to convert .FBX to Collada before running Horde3D's ColladaConv. Pain in the ass, maybe. And maybe I can revert to Blender 2.57 before they disabled animation export (although then, of course, I'll have to contend with whatever possible issues the Collada team is working to fix). For the moment, I just needed to see if I could put a mesh through the pipeline and have it draw. So I built one in Blender, textured it real quick, and dumped it as a Collada export. I fed it to ColladaConv, which generated the material, scene and .geo files. I open up the XML documents it generated just to peer at their guts; everything looks okay, but will it render?

I copied some more setup stuff from the Horde3D samples, dumping it into my BasicContext:init function: stuff to setup a camera, stuff to instance a light node, stuff to instance a copy of my sign mesh that I built in Blender. Then I modify the render section to render things. Nothing I have done here is official by any means; in a real game, all of this type of initialization and instancing needs to belong somewhere that is more flexible and not cluttering up the main section. For now, though, like I said... I just need to see.

With the changes made, I fire up the app and execute the startup script.

horde1.jpg


Don't be deceived by the crappy-looking sign graphic; everything seems to be working perfectly. Doing a static environment mesh for the game looks like it will truly be simple. The ColladaConv tool worked, I didn't have to do anything weird to the generated files afterward. I do notice some errors in the Horde3D log, about some missing textures. Further investigation reveals that the pipeline XML file imports some global settings, and in those global settings are declarations for ambient maps, so obviously I'll need to do a clean pass of all the existing shader and pipeline files cobbled from the Horde3D samples, to sanitize them of anything I don't want. But even with the missing textures, the thing works. I don't know why I'm always surprised when stuff just works.

Of course, I've only got one model on screen now, with hard-coded camera. Now I need to start doing some actual design: figure out my asset directory structure, wrap up some of that ugly hard-coded initialization, wrap up the camera, generalize the creation of static geometry instances, etc... All of the fun, detail-oriented design stuff that seems so hard to get right sometimes.

Now, in order to ensure that Horde3D and SFML are going to cooperate, I'm just going to get a quick SFML sprite in place to make sure:

horde2.jpg


Seems to be working. I'm not really going to do a blow-by-blow of what I was doing to draw the sign and the overlay. It's not important, and it's not going to be sticking around. Coming up, I'm going to start putting things in their place and figuring out how to organize and build a basic static-geometry scene. Tune-in next time...
5 likes 0 comments

Comments

Nobody has left a comment. You can be the first!
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Profile
Author
Advertisement
Advertisement