🎉 Celebrating 25 Years of GameDev.net! 🎉

Not many can claim 25 years on the Internet! Join us in celebrating this milestone. Learn more about our history, and thank you for being a part of our community!

Building An Isometric Game Using Horde3D (Part 5)

Published August 14, 2011
Advertisement
In the previous post, I started to experiment with skeletally animated characters. I created a test character, HordeMan, as a composite of several different components in a manner common to RPGs that allow the player to change appearance based on equipment. I discovered, via a post on the Horde3D forums, that attaching several different meshes to a single skeleton is not currently possible (though the maintainer of Horde3D indicated it was a good idea and likely to be implemented in the future) so in order to make a component system like this work it seems I have to do one of two things:

1) As in my previous, set up each component piece with its own skeleton instance, and just keep the various skeletons in sync by supplying identical animation steps. This performs redundant skinning-matrix computations.

2) Write a tool that will crunch all component base meshes into a single H3D geometry resource, then specify sub-components by instancing Mesh nodes in the base Model node. This will animate everything from the same skeleton, but forces all of the sub-component meshes to be in the same geometry resource. (Of course, this might not actually be a bad thing, depending on how meshes are drawn, since having all the character data be in a single buffer could be faster.) However, if there are a very large number of component mesh types, this could inflate the geometry resource quite a bit. However, this last is not likely to be a problem for me; as an indie, I do all my own artwork as well as the programming, and I certainly won't have the time to create hundreds or thousands of sub-component meshes. Likely only two or three base meshes per component, with most of the variety coming from texture. This is likely the route I will go once I have written the tool to compact multiple geometry resources into a single one. I'll write more on how this works once I have done.

With that out of the way (or at least securely procrastinated for the next post), I want to now begin complicating-up the shaders a bit in order to introduce two neat things: lightmap-based lighting that uses Goblinson Crusoe's lighting system (or a good approximation thereof), and ground texture splatting/blending to create more ground variety, over what a strict tile-based scheme provides. A texture splatting system allows me to create ground texture that smoothly blends over an area as large or as small as I desire. The shader for it is fairly simple as well.

To begin, I've elected to go with a base layer and 4 blended layers, making 5 total terrain types, which is actually quite a lot for my purposes. In actual practice, while I experiment with things, I am going with a base layer and 2 blended layers only, since my card really is not powerful at all. Nevertheless the shader extends easily enough to more layers. The basic idea of splatting, of course, is that a piece of geometry is drawn with a number of different textures defining the terrain textures to splat, and yet another texture that defines the blending. Each component in the blend texture (r, g, b, a) defines a blending factor for a corresponding terrain texture layer, and the blending is performed as a simple interpolation. Here is my first stab at the shader. (Important note: I am a very recent dabbler in GLSL, so I wouldn't take any of my shaders as gospel, if I were you. In fact, this project constitutes the vast majority of my GLSL shader-writing experience. The shader works fine, but whether or not it's the most efficient way, I couldn't say.)


[[FS_GROUNDBLEND]]

uniform sampler2D layerBlend;
uniform sampler2D layerOne;
uniform sampler2D layerTwo;
uniform sampler2D layerThree;
//uniform sampler2D layerFour;
//uniform sampler2D layerFive;

varying vec4 pos;
varying vec2 texCoords;

void main(void)
{
vec2 blenduvs=vec2(pos.x/128.0, pos.z/128.0);
vec4 blends=texture2D(layerBlend, blenduvs);
vec4 l1 = texture2D(layerOne, texCoords);

vec4 c=texture2D(layerTwo, texCoords);
l1 = l1 + (c-l1)*blends.r;
c=texture2D(layerThree, texCoords);
l1 = l1 + (c-l1)*blends.g;
//c=texture2D(layerFour, texCoords);
//l1 = l1 + (c-l1)*blends.b;
//c=texture2D(layerFive, texCoords);
//l1 = l1 + (c-l1)*blends.a;

gl_FragColor=vec4(l1.rgb, 1.0);
}


You'll note that the shader is very simple. The terrain textures are specified as layerOne through layerFive (with four and five commented out to show mercy to my poor GPU). The splatting constitutes a simple blend of each layer on top of the base layer using linear interpolation. Let's go ahead and see how it looks:

hordesplat1.jpg

Pay no attention to the army of clones; they're for stress-testing. As you can see, the splatting seems to work pretty well. To create the ground, I set up another ground scene node piece that specifies a different class of material, and lists the terrain textures that should be blended on that piece. Here is the material .XML for it:













The textures were chosen randomly from my texture library.

In production code, of course, I would remove the hard-coded splat-map size constants (128x128 at present, as you can see in the shader) and allow the dimensions to be specifiable via uniforms. Also, rather than having a pre-built texture for the blend map, I would allow the level generator to write to the blend map texture when the level is constructed.

I then needed to create another context for the rendering of splatted geometry in the shader, and added another step in the pipeline .XML file:

[source]














[/source]

So, splatting seems to work pretty well. While I was mucking about in the shader files, I also wanted to implement lighting. Lighting is going to be light-map based; in much the same way that a single texture blendmap that covers the entire level is used for terrain splatting, another texture will be used for lighting. Lighting calculations will be performed using LoS, fog of war, negative lights for areas of darkness, etc... and then the final light values will be sub-rect'ed into the lightmap texture prior to the render cycle. In order to test the idea, I created a test lightmap in Gimp and altered the character shader as follows:


[[VS_CHARACTER]]
// =================================================================================================

#include "shaders/utilityLib/vertCommon.glsl"
#include "shaders/utilityLib/vertSkinning.glsl"

uniform mat4 viewProjMat;
uniform vec3 viewerPos;
attribute vec3 vertPos;
attribute vec2 texCoords0;
attribute vec3 normal;

varying vec4 pos, vsPos;
varying vec2 texCoords;
varying float vertexColor;

void main( void )
{
mat4 skinningMat = calcSkinningMat();
mat3 skinningMatVec = getSkinningMatVec( skinningMat );

// Calculate normal
vec3 _normal = normalize( calcWorldVec( skinVec( normal, skinningMatVec ) ) );

vec3 light=vec3(0.366508, 0.85518611, 0.366508);
vertexColor = dot(_normal, light);

pos = calcWorldPos( skinPos( vec4( vertPos, 1.0 ), skinningMat ) );

vsPos = calcViewPos( pos );

// Calculate texture coordinates and clip space position
texCoords = vec2( texCoords0.s, 1.0-texCoords0.t );
gl_Position = viewProjMat * pos;
}

[[FS_CHARACTER]]
// =================================================================================================
uniform sampler2D albedoMap;
uniform sampler2D lightMap;
varying vec4 pos, vsPos;
varying vec2 texCoords;
varying float vertexColor;

void main(void)
{
vec4 albedo = texture2D(albedoMap, texCoords);
vec2 blenduvs=vec2(pos.x/128.0, pos.z/128.0);
vec4 light=texture2D(lightMap, blenduvs);
//if (albedo.a <1.0) discard;
gl_FragColor=vec4(albedo.rgb*light.rgb*vertexColor, 1.0);

}



I also altered the splatting shader:


[[FS_GROUNDBLEND]]

uniform sampler2D layerBlend;
uniform sampler2D layerOne;
uniform sampler2D layerTwo;
uniform sampler2D layerThree;
//uniform sampler2D layerFour;
//uniform sampler2D layerFive;
uniform sampler2D lightMap;

uniform vec4 MapSize;

varying vec4 pos;
varying vec2 texCoords;

void main(void)
{
vec2 blenduvs=vec2(pos.x/128.0, pos.z/128.0);
vec4 blends=texture2D(layerBlend, blenduvs);
vec4 light=texture2D(lightMap, blenduvs);
vec4 l1 = texture2D(layerOne, texCoords);

vec4 c=texture2D(layerTwo, texCoords);
l1 = l1 + (c-l1)*blends.r;


c=texture2D(layerThree, texCoords);
l1 = l1 + (c-l1)*blends.g;

//c=texture2D(layerFour, texCoords);
//l1 = l1 + (c-l1)*blends.b;

//c=texture2D(layerFive, texCoords);
//l1 = l1 + (c-l1)*blends.a;

gl_FragColor=vec4(l1.rgb*light.rgb, 1.0);
}


This adds another texture look-up into the lightMap texture, using the same coordinates calculated as for terrain blending, then scales the output fragment color by this value. Also, in the character vertex shader, I added some directional lighting (again, using a hard-coded constant that will soon be replaced by a specifiable uniform for light direction) to mitigate some of the flatness of the rendered HordeMan as in previous screenshots. So, let's go ahead and see how it looks:

hordesplat2.jpg

You'll note that HordeMan, while still blocky (and badass!) is shaded a bit better than before, and the lighting seems to be working pretty well. Framerate is still acceptable, although once I start adding lit and blended walls back in it's going to drop even further. Still, given that this is being coded on a circa 2005 Toshiba Satellite laptop from Best Buy, I suppose it's good enough. You'll also note that HordeMan's sword, it turns out, was actually a light saber this whole time! Badass! (I forgot to specify the lightmap in the sword material, so that is just a happy coincidence. OR IS IT??!!)

These additions now pave the way to doing more complicated level terrain, and bringing in the lighting system from GC, allowing for some better-looking levels, fog of war, etc...

While I was at it, I cleaned up the framework a bit. I brought in the object/component system I began developing for Goblinson Crusoe (as detailed here) and portioned out the camera control into its own component. I brought in a basic map view component, and implemented the framework for handing LogicUpdate and AddFrameTime messages to game objects. Then I set up 40 randomly placed "enemies". They possess only an animated entity component, and no logic as of yet, but they do receive ticks so they are just one component away from wandering randomly around the map. Even with this additional processing, I'm still maintaining ~45 FPS, so I am actually quite happy with how it works so far. Here is the new startup file:

[spoiler]
[source]
-- startup.lua

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

-- Requires
require 'tablesaveload'
require 'kernel'
require 'class'
require 'h3dresources'
require 'gamecontext'

-- Components
require 'cameracontrolcomponent'
require 'MapViewComponent'
require 'animatedentitycomponent'


configversion=2

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

-- Load/Restore configuration
function loadConfig(configname)
local config=table.load(configname)
if config==nil or config.version~=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=false,
version=configversion,
nodescreensize=128,
}

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=4
if config.windowed==false then style=style+8 end

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

-- Horde3D setup
h3dSetOption( H3DOptions.LoadTextures, 1 )
h3dSetOption( H3DOptions.TexCompression, 0 )
h3dSetOption( H3DOptions.FastAnimation, 0 )
h3dSetOption( H3DOptions.MaxAnisotropy, 4 )
h3dSetOption( H3DOptions.ShadowMapSize, 2048 )
--h3dSetOption( H3DOptions.WireframeMode, 1)

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


-- Build a test level
function buildTestLevel()
local x,y
for x=-25, 25, 1 do
for y=-25, 25, 1 do
local f=h3dAddNodes(H3DRootNode, Horde3DResourceManager:getResource(H3DResTypes.SceneGraph, "blendplane", 0))
h3dSetNodeTransform(f, x*2, -0.25, y*2, 0,0,0, 2,2,2)
end
end
end

--local blendground=Horde3DResourceManager:getResource(H3DResTypes.SceneGraph, "blendplane", 0)

--n=h3dAddNodes(H3DRootNode, blendground)
--h3dSetNodeTransform(n, 10, 0.5, 10, 0,0,0, 2,2,2)

-- Test texture writing
buf=CArray2Drgba(128,64)

for x=0,127,1 do
for y=0,63,1 do
buf:set(x,y, anl.SRGBA(x/128, y/64, 0, 1))
end
end

rnd=anl.CMWC4096()
rnd:setSeedTime()


-- Build a test player
local player = BasicGameStateContext:createObject()
player:addComponent(CameraControlComponent(player, {height=6, angle=30.0}))
player:addComponent(MapViewComponent(player))
player:addComponent(AnimatedEntityComponent(player, {name="hordeman", scale=0.25}))

player:handleMessage("ActivateCamera")
player:handleMessage("UpdateObjectVisualPosition", {x=0, y=0, z=0})

BasicGameStateContext:addToLogicGroup(player)
BasicGameStateContext:addToFrameGroup(player)
BasicGameStateContext.controlledobject=player

objects={}
for c=1,80,1 do
objects[c]=BasicGameStateContext:createObject()
objects[c]:addComponent(AnimatedEntityComponent(objects[c], {name="hordeman", scale=0.25}))
BasicGameStateContext:addToLogicGroup(objects[c])
BasicGameStateContext:addToFrameGroup(objects[c])
objects[c]:handleMessage("SetObjectLogicalPosition", {x=rnd:get01()*50-25, y=0, z=rnd:get01()*50-25})
end


buildTestLevel()


success=mapHorde3DTexture(h3dFindResource(H3DResTypes.Texture,"tilefloor.tga"), buf)
if success then print("success!") else print("failure!") end

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

shutdown()

[/source]
[/spoiler]

The state context stuff has also been portioned out into its own file. The state context is where the game objects "live", and it has responsibility for tracking them, updating them, and governing their creation and destruction:

[spoiler]
[source]
-- Basic Game State Context

require 'baseobject'
require 'class'
require 'h3dresources'



-----------------------------------

-- Utilities
-- Create a font
function createFont(name)
local font=sf.Font()
font:LoadFromFile(name)
return font
end

-- Create a random generator
function createRandomGenerator(seed)
local gen=anl.CMWC4096()
if seed then gen:setSeed(seed) else gen:setSeedTime() end
return gen
end

BasicGameStateContext=
{
logicgroup={},
framegroup={},
controlledobject=nil,

nextID=1,
objects={},
kill_list={},

font=createFont("C:/Windows/Fonts/arial.ttf"),
rnd=createRandomGenerator(),

clock=sf.Clock()
}

function BasicGameStateContext:addFrameTime(elapsed, percent)
-- AddFrameTime to all active objects registered in framegroup
local msg={elapsed_time=elapsed, percent_within_tick=percent}

local i,j
for i,j in pairs(self.framegroup) do
j:handleMessage("AddFrameTime", msg)
end
end

function BasicGameStateContext:updateLogic()
self:purgeKillList()

-- Update logic
local i,j
for i,j in pairs(self.logicgroup) do
j:handleMessage("UpdateLogic")
if self.bail_on_update then self.bail_on_update=false return end -- Generated a new level, lists no longer valid so return
end
end

function BasicGameStateContext:render()
h3dRender( Horde3DResourceManager.currentcamera );
h3dFinalizeFrame();
h3dutDumpMessages();

window:RestoreGLStates()

-- Draw UI
local text=sf.Text(sf.String("FPS:"..kernel.fps), self.font, 25)
window:Draw(text)

window:SaveGLStates()
window:Display()
end

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

if event.Type==sf.Event.KeyPressed then
if event.Key.Code == sf.Keyboard.S then
local image=window:Capture()
--image:CopyScreen(window)
image:SaveToFile("screen"..self.clock:GetElapsedTime()..".jpg")
end
end
end


-------------------------------------------

-- Reverse project the given mouse coords against the Y plane specified
function BasicGameStateContext:reverseProjectOnPlane(mousex, mousey, y)
-- mousex and mousey are in screen coords.
-- divide them by config.screenwidth/height to get normalized device coords

local ox,oy,oz,dx,dy,dz=0,0,0,0,0,0
ox,oy,oz,dx,dy,dz = h3dutPickRay(Horde3DResourceManager.currentcamera, mousex/config.screenwidth, (config.screenheight-mousey)/config.screenheight, ox, oy, oz, dx, dy, dz)

-- Normalize d
local len=math.sqrt(dx*dx+dy*dy+dz*dz)
dx=dx/len
dy=dy/len
dz=dz/len

-- Calculate t for Py=y
-- If dy==0, though, then we have a problem
if dy==0 then
return 0,0,0
end

local t=(y-oy)/dy

--Now, solve for Px and Pz
local Px = ox + t*dx
local Pz = oz + t*dz

return Px,y,Pz
end

function BasicGameStateContext:killObject(o)
local guid=o.guid
self.objects[guid]:handleMessage("KillObject")
self.kill_list[guid]=guid
self:removeFromLogicGroup(o)
self:removeFromFrameGroup(o)

end

function BasicGameStateContext:killAllObjects()
local i,j
for i,j in pairs(self.objects) do
j:handleMessage("KillObject")
self:removeFromLogicGroup(j)
self:removeFromFrameGroup(j)
end
self.objects={}
self.logicgroup={}
self.framegroup={}
collectgarbage()
end

function BasicGameStateContext:purgeKillList()
local i,j
for i,j in pairs(self.kill_list) do
self.objects[j]=nil
end

self.kill_list={}
end

function BasicGameStateContext:addToLogicGroup(o)
self.logicgroup[o.guid]=o
end

function BasicGameStateContext:removeFromLogicGroup(o)
self.logicgroup[o.guid]=nil
end

function BasicGameStateContext:clearLogicGroup()
self.logicgroup={}
end

function BasicGameStateContext:addToFrameGroup(o)
self.framegroup[o.guid]=o
end

function BasicGameStateContext:removeFromFrameGroup(o)
self.framegroup[o.guid]=nil
end

function BasicGameStateContext:clearFrameGroup()
self.framegroup={}
end

function BasicGameStateContext:createObject()
local o=Object(self.nextID)
self.objects[self.nextID]=o
self.nextID = self.nextID+1
return o
end

function BasicGameStateContext:getObject(guid)
return self.objects[guid]
end
[/source]
[/spoiler]

The animated entity component, that manages an animated character, is still not fleshed out. It sets up a single animation stage and just endlessly cycles it around and around, with no logic to handle switching animations:

[spoiler]
[source]
-- Animated entity component

require 'class'
require 'h3dresources'

AnimatedEntityComponent=class(function(self, owner, args)
self.node=h3dAddNodes(H3DRootNode, Horde3DResourceManager:getResource(H3DResTypes.SceneGraph, args.name, 0))
local res=Horde3DResourceManager:getResource(H3DResTypes.Animation, args.name, 0)
h3dSetupModelAnimStage(self.node, 0, res, 0, "", false)

self.framecount=h3dGetResParamI(res,H3DAnimRes.EntityElem, 0, H3DAnimRes.EntFrameCountI)
print(self.framecount)

self.curtime=0

self.x=0
self.y=0
self.z=0
self.y_angle=0
self.scale=args.scale
end)


function AnimatedEntityComponent:UpdateFacingVector(owner, args)
-- Calculate the angle represented by the vector
local a=math.atan2(args.x, args.z)
self.y_angle=a*180.0/3.14159265
local scale=self.scale
h3dSetNodeTransform(self.node, self.x, self.y, self.z, 0, self.y_angle, 0, scale,scale,scale)

end

function AnimatedEntityComponent:UpdateObjectVisualPosition(owner, args)
self.x=args.x
self.y=args.y
self.z=args.z
local scale=self.scale
h3dSetNodeTransform(self.node, self.x, self.y, self.z, 0, self.y_angle, 0, scale,scale,scale)
end

function AnimatedEntityComponent:KillObject(owner, args)
h3dRemoveNode(self.node)
self.node=0
end

function AnimatedEntityComponent:AddFrameTime(owner, args)
self.curtime=self.curtime+args.elapsed_time*2
if self.curtime>self.framecount-1 then self.curtime=1 end
h3dSetModelAnimParams(self.node, 0, self.curtime, 1)
end

[/source]
[/spoiler]

The player is given a MapView component, which is a simple component I made for GC that lets you take a walk around the map. It does no logic save for moving, ie no interaction with other entities in the map:

[spoiler]
[source]
-- Camera control component

-- args:

-- camera: h3d Camera node
-- height: camera height above ground
-- angle: azimuth angle of camera
require 'class'
require 'h3dresources'

-- Utility functions
-- Create isometric camera
function createIsometricCamera()
local cam=h3dAddCameraNode(H3DRootNode, name, Horde3DResourceManager:getResource(H3DResTypes.Pipeline, "iso", 0))

h3dSetNodeParamI( cam, H3DCamera.ViewportXI, 0 )
h3dSetNodeParamI( cam, H3DCamera.ViewportYI, 0 )
h3dSetNodeParamI( cam, H3DCamera.ViewportWidthI, config.screenwidth )
h3dSetNodeParamI( cam, H3DCamera.ViewportHeightI, config.screenheight )

h3dSetNodeParamI( cam, H3DCamera.OrthoI, 1)

local screennodes_x = config.screenwidth / config.nodescreensize
local screennodes_y = config.screenheight / config.nodescreensize

h3dSetNodeParamF( cam, H3DCamera.LeftPlaneF, 0, -screennodes_x/2)
h3dSetNodeParamF( cam, H3DCamera.RightPlaneF, 0, screennodes_x/2)
h3dSetNodeParamF( cam, H3DCamera.BottomPlaneF, 0, -screennodes_y/2)
h3dSetNodeParamF( cam, H3DCamera.TopPlaneF, 0, screennodes_y/2)
h3dSetNodeParamF( cam, H3DCamera.NearPlaneF, 0, 0.0)
h3dSetNodeParamF( cam, H3DCamera.FarPlaneF, 0, 30.0)

return cam
end

CameraControlComponent=class(function(self, owner, args)
self.cameranode=createIsometricCamera()
self.height=args.height
self.angle=args.angle
local angle=args.angle * 3.14159265 / 180.0

local hypotenuse=1.0 / math.sin(angle)
local translen = math.cos(angle) * hypotenuse
translen = math.sqrt((translen*translen)/2.0)

self.heightscale = self.height * translen
end)

function CameraControlComponent:UpdateObjectVisualPosition(owner, args)
h3dSetNodeTransform(self.cameranode, args.x+self.heightscale, self.height, args.z+self.heightscale, -self.angle, 45.0, 0, 1, 1, 1)
end

function CameraControlComponent:ActivateCamera(owner, args)
Horde3DResourceManager.currentcamera=self.cameranode
end

function CameraControlComponent:KillObject(owner, args)
h3dRemoveNode(self.cameranode)
self.cameranode=nil
end

[/source]
[/spoiler]

Some changes were made to allow specifying an azimuth angle, rather than hardcoding it at 30 degrees above the horizon. The game state context implements some utility functions such as reverse-projecting the mouse location onto the Y=0 ground plane of the world, in order to calculate movement direction.

Next up, I'm going to get rid of the magic numbers in the shaders, incorporate the lighting into the general shader, and try to put together a more comprehensive test case to evaluate the performance.
2 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