* You are viewing Posts Tagged ‘Titan Quest’

Eliminating Exporting in an Asset Pipeline

An asset (like a model) undergoes a number of steps from in the journey from an artist’s tool to real-time display in a game engine. These steps are known collectively as the asset pipeline. The pipeline for a typical game engine looks something like this:

The proprietary format on the left is the format that the content creation tool natively saves. For 3D Studio MAX this is the .MAX format. This is an undocumented binary format that relies on installed plug-ins to load, so it’s not really possible or practical for our own tools to read it directly. In order for our tools to load the asset it first must be converted into an intermediate format.

The intermediate format is either a standard format like COLLADA or a custom defined format that is easy for our tools to read. This file is exported from the content creation tool and stores all of the information we could possibly desire about the asset, both now and in the future. Normally this export process is done by selecting Export for the tool’s menu and then choosing the name of the file to write. Since the intermediate format is designed to be general and easy to read, this intermediate format isn’t optimized for space or fast loading in-game. To create the game-ready format, we compile the intermediate format into a game format.

The game format is an optimized format that is loaded by the engine at run-time. The game format file is generated by our tools from the intermediate format. The main reason we don’t directly export from 3D Studio MAX into our game format is that we may want to change it in the future. If the game format was created by our exporter then any change to the format or creation process would require re-exporting all of our assets. Having an intermediate format also allows us to have different versions of the game format that are targeted at different release platforms (PC, console, etc.).

One annoyance with this setup is that the proprietary file and the intermediate file have to be kept in sync — that is whenever a change is made to the .MAX file, the artist needs to be re-export the intermediate format for the change to appear in game. Typically the proprietary file and the intermediate file would both be kept in some sort of revision control system such as Perforce. In addition to needing to be re-exported, the files need to be checked in as well. While these both seem like they would be easy, on a large team with tens of thousands of assets, mistakes will happen.

The ExportObject in 3D Studio MAX

A solution that we used at Iron Lore was to automatically export the intermediate format when the file was saved in 3D Studio MAX. The way this was done was with a special plug-in. This plug-in was an object called an ExportObject that the artists would place in the scene. The object looked like a floppy disk (how quaint!) and the only thing it really did was implement a custom save handler. As mentioned earlier, the proprietary .MAX format requires plug-ins to load it, and the reason for this is that each plug-in is responsible for saving its own data. In the case of the ExportObject, during the “save” it would export the entire scene to the proprietary file format.

There are a few options for how to save this data. The first is to save directly to a new file on disk. The second is to write the data in-place in the .MAX file (i.e. where the plug-in is supposed to save its data). The third is to append the data to the end of the .MAX file.

The first option is unattractive because we still have two files that would need to be kept in sync in Perforce. The third option is the one that we used at Iron Lore and is possible because 3D Studio MAX will ignore data at the end of the file when loading a .MAX file. Implement this system again today, I would chose the second option because it doesn’t rely on this quirk of the loader.

Once the data is auto-exported, it’s a simple matter to load it in our own tools. In the case of appending the data to the end of the .MAX file, I included the length of the intermediate data as the final 4 bytes. This makes it very simple to seek backwards and read only the required data.

If the data is written in-place, then a special token needs to be used to locate the appropriate block of data. This token is simply a string of bytes that is unlikely to appear anywhere else in the file and signifies the beginning of the data. If this string is long enough, the chance of it appearing elsewhere in the file is astronomically small. If this doesn’t seem robust, consider that there is roughly a 1 in 108 chance that you will be struck by lighting on any particular day; there is a 1 in 1068 chance that an arbitrary 8-byte string will appear in a typical sized .MAX file.

Since the ExportObject is a persistent member of the scene, it can also store options that might be necessary for exporting. Since the export options are stored along with the .MAX file, the file can be re-saved/exported without the operator having to know or re-input the correct options.

This system worked very well at Iron Lore — in the future I’ll describe our first approach which was much more problematic!. The only thing to keep in mind is that the export must be fast so that it doesn’t noticeably slow down the saving process for the artists.

Blending Terrain Textures

One of the most common methods for texturing a height map terrain is with multiple tiling layers.  These texture layers are stacked on top of each other and blended together to create the final textured look. In addition to the tiling texture, each layer also has an opacity map which controls how much of the texture is blended in at any point on the terrain. Unlike the tiling texture this opacity map is stretched over the entire terrain and is therefore very low detail.

Textured Terrain in Titan Quest

In a simple example, our terrain could have two layers: sand and grass. Anywhere the opacity map for the grass layer was painted 1 (opaque) we would just see grass. Where the opacity map was 0 we would see sand, and where it was 0.5 we’d see a mix of sand and grass. Note that the bottom layer doesn’t have an opacity map since the base layer must appear everywhere. This blending operation for two textures is implemented like this:

float4 blend(float4 texture0, float4 texture1, float layerOpacity)
{
    return lerp(texture0, texture1, layerOpacity);
}

The problem with this basic technique is that alpha blending a grass texture with 50% opacity over a sand texture doesn’t really look very good when you look at a closely:

The blended result on the bottom resembles green sand more than a patch of sandy grass.  If the textures were more closely related this would work better, so this can be solved by adding additional artist crafted transition textures.  This however creates more work for the artists, and requires us to render an additional texture layer. Finally, for every combination of textures that we’re going to layer we’d need a different transition texture, and ultimately we might need several.

There is however what I consider to be a better an easier solution which we successfully used on Titan Quest. The idea is that instead of drawing 50% of each texture at every pixel, at 50% of the pixels we draw one texture and at the other 50% we draw the second. This is what it looks like when we do this:

To implement this, we store a mask in the alpha channel of each of the tiling textures. This mask uses the entire range of values and determines whether or not a texel will be displayed when the layer has a particular opacity. Specifically, a texel is displayed if the mask is less than the opacity value.  This new, splotchy blending technique is simply implemented like this:

float4 blend(float4 texture0, float4 texture1, float layerOpacity)
{
    if (texture1.a < layerOpacity)
    {
        return texture1;
    }
    else
    {
        return texture0;
    }
}

The result has a more natural look, and the splotching pattern can be tailored for each type of texture individually. While this grass texture uses a noisy pattern, textures with more structure — such as stones — could be setup so that the blending follows the structure.  With stones, that the alpha mask can be created with high values in the cracks and low values on the flat surfaces.  When this is blended on top of a grass texture, the grass will first appear only in the cracks as the layer opacity is increased.

Because this is a binary decision, the result has hard edges and suffers from aliasing.  It looks best if we smooth out the edges between the textures by doing a small amount of regular alpha blending between the textures. Here’s one way to implement it, although other methods can certainly be used (for Titan Quest we used a simpler method to fit within the instruction limits of ps1.0):

float4 blend(float4 texture0, float4 texture1, float layerOpacity)
{
 
    const float blendRange = 0.1;
 
    if (texture1.a < layerOpacity - blendRange)     
    {
         return texture1;
    }
    else if (texture1.a > layerOpacity + blendRange)
    {
        return texture0;
    }
    else
    {
        float f = (texture1.a - layerOpacity + blendRange) / (2 * blendRange);
        return lerp(texture1, texture0, f);
    }
}

Here’s what the final result looks like:

Although these examples show two textures, any number of layers can be blended this way, just like with the basic technique. This type of blending also isn’t limited to terrain textures, and interesting effects can be produced when the layer opacity is animated. For example,you can see the same type of blending at work in this prototype of dynamic infestation for Natural Selection 2: