We were looking for an optimal way to build our levels from inside 3D Studio Max. Lofting and other techniques used earlier to make level meshes were rigid to changes. We needed a method where changes to the level mesh could be incorporated without too much effort. We wanted to have a system within Max which had the ease associated with CSG (Constructive Solid Geometry). When I saw the way Max could boolean objects, especially even objects which were non intersecting, I thought I could make use of this feature in Max to build and derive meshes which were like the ones a CSG builder would probably output.
This article does not discuss the actual script functions that I used, but will explain the logic and the way I have used Max script to build a small system within Max to build game levels for our game engine. One could always adapt and create scripts that meet their own needs.
Using Max Script to Build a Game Level Building System Inside Max
The first and foremost task is to understand what your game engine exactly requires; whether the level mesh can be just a collection of polygons mapped with different textures, or if they have to comply with certain criteria necessary for it to be used by your game engine. It can vary depending on how exactly your game engine wants the level data to be organized. Most game engines prefer the world data to be built or organized in a particular way.
In our case, the game engine needed the level mesh to be BSP (Binary Space Partitioning) friendly. The mesh exported from Max had to fulfill two basic criteria. First, every edge in the mesh should be two manifold, and second no face should intersect another. If the mesh fulfills these two conditions then there will be no cracks or gaps in it.
In addition to our game engine, we also have our own lighting editor, which we use to light up these game levels that we create. We also place other special effects such as fire, smoke, etc within the lighting editor. The lighting editor also needs the mesh to be BSP friendly in order to calculate lights faster. Moreover, it requires the mesh to have light maps assigned and mapped to them, which it fills when the level is being lit.
As mentioned earlier, we wanted our system to have the ease of a CSG level builder. We adapted Boolean functions inside Max to create an environment like CSG. The level building starts with defined primitives. Later using Boolean functions, we derived a level mesh from these primitives that is BSP friendly. The Boolean operations are covered in the second part 'Building the World'.
One could probably put in more time and use a BSP tree to build the level mesh from these primitives. A person building a game level using this in Max, starts with primitives like in a CSG level builder. The actual world is built or updated every time the user presses the Build World button.
First, we have to define the primitives that the user can use to build the world. A check on other CSG editors like Genesis and Unreal Ed, gives a fair idea on the various primitives that they use. Since the script does Boolean operations on the primitives, the primitives should support Boolean operations without any errors. Almost all the Max standard-objects such as the Box, Sphere, Cylinder, Pyramid, Cone, Tube, and Torus worked well with Boolean operations. Some of the Extended Objects are useful too and can be booleaned properly.
Since the user wouldn't know which primitive could be used and which couldn't, a customized GUI can be made which has all the primitives that could be used, as buttons. While building the system, Max functions, features and even standard buttons have been adapted wherever possible. However, creation of all the primitives using custom scripts is preferred, since it would give more control over the creation of primitives and assignment of various properties to them.
Figure 1: The customised Max interface shows buttons, which call appropriate script files. The primitives that can be used to build the world have all been set on the left side.
In addition to these primitives, one could make their own, which they deem as useful. Four primitives, a ramp, ramp with railings, stairs and stairs with railing were made for this system. This is covered later in the article under Creating Custom Primitives.
The user building a level mesh using this system in Max starts building primitives. The primitives are assigned appropriate materials and textures. The primitives can even have different materials assigned to different faces.
The primitives can be either hollow or solid like in a CSG builder. Through script, a user property called bool is set for each of the primitives. This bool property saves either one or two against it. One stands for hollow primitive and two stands for solid primitive. By default, the primitives created are set to hollow.
After creation, these primitives have to be added into the scene by clicking the Add CreateID button. This script assigns a CreateID user property to the object. The CreateID property stores a value against it, which determines the order of creation of these primitives. This order is needed while doing Boolean operations. The user can now press the Build World button to build or update the level mesh.
When the user presses the Build World button, the script first searches through the scene to find and collect objects that have a CreateID property. Then it runs through the collection of objects or primitives as we refer to them, and sorts them in ascending order according to the value stored against the CreateID property. This is important since Boolean functions could end up with different results depending on the order in which the objects are booleaned.
At first the script was depending on Max, since internally, Max seemed to have some kind of order which matched the order of creation of these primitives. However, when any primitive were hidden or frozen and later unhidden or unfrozen the order seemed to change. Hence, the Assign CreateID button was introduced which determined the order of creation of the primitives. This later made the system much more flexible, since the user could change the order of the primitives at any time by using a button called Force CreateID. The Force CreateID script let the user assign any CreateID for the primitive thereby enabling the user to move the primitive up or down the order of creation.
Getting back to the Build World script, once the primitives are in order, the script starts by creating an object named Parent. Henceforth, this object will be referred to as the parent. The parent is basically a box constructed through script. The total bounding area of all the primitives is ascertained, and the parent size is set to be slightly bigger than this. Then the parent is positioned such that it covers all the primitives. This is done because in most CSG environments one starts with solid space and starts carving out hollow spaces inside it. The parent object, being a standard solid box in Max, is much like solid space in a CSG environment.
Once the parent is created, each primitive is taken in the order in which they now exist in the collection of primitives, and booleaned with the parent. While booleaning these primitives with the parent, the script checks whether the primitive being booleaned is a hollow or solid primitive. This property is received from the 'bool' property of these primitives. All primitives that do not have this property against them are set to be hollow as default. The Boolean operation is set to subtraction if the primitive is hollow while booleaning it with the parent. If the primitive is solid then the Boolean operation is set to union.
Moreover, while booleaning the primitives, the pick object is set to be an instance of the actual primitive being booleaned. This way surprisingly even after multiple booleans, any mapping change done to any primitive used in boolean operations, affect the mapping in the booleaned object or the parent in this case. This is very useful since if the user later changes the mapping on any of the primitives it is instantly updated in the parent object.
The script also does a weld-on-threshold on the vertices of the parent after every Boolean. This is done to ensure that there are no open edges in the parent object. If the parent develops open edges after any Boolean operation, further Boolean operations end up with undesired results. It is also better not to have any open edges if you want the mesh to be BSP friendly. The code for this appears in Listing 1.
Once all the booleans are done, the parent is almost complete. Now, all the edges of the parent object are set to invisible. Then all the primitives are set to Box mode. This is a small trick to ensure that only the parent object is visible in a shaded viewport and only the primitives are displayed in the viewports set to wireframe.
Figure 2: The wireframe viewports show the primitives while
By doing this, the Building of the world is complete. We now have the primitives and the parent, which is the built world.
Building the Final Export Mesh
Once the user has finished building the level mesh, they would export this out of Max and into the game engine or the lighting editor in our case. In case the game engine was using vertex lighting then there wouldn't have been much to do on the parent before exporting it. It would be more or less ready to export. However, our game engine and lighting editor requires the level mesh to have lightmaps assigned and mapped to them for it to light up later. Because of this, an export mesh has to be built which has appropriate lightmaps assigned to it.
When the user presses the button Build Export Mesh, the script file first makes a copy of the parent object, which shall be henceforth referred to as parent2. Then it checks for the primitives in scene that are not hidden and collects them into a list, which we shall call as the 'primitive list'. After which it hides everything in scene except parent2.
Parent2, being a booleaned object, will have a multi material assigned to it. The script breaks the faces in parent2 according to the sub materials being used. Then these detached meshes are reassigned with the appropriate materials. The code for this appears in Listing 2.
After this, each of these objects is further broken into smaller planar objects. The explode function in Max is used by the script to do this, hence the explode threshold determines the angle for breaking up into smaller objects. This occurs because a planar mapping is done on these objects later, for use by lightmaps. The code for this appears in Listing 3.
When the mesh is broken into planar surfaces, some of these planar surfaces (mostly the floor) turn out to be huge objects that stretch across many primitives. We call these objects rogue objects. These rogue objects require huge lightmaps, and chunks of this lightmap would go to waste due to many unmapped areas. Therefore, these objects are checked and broken down, such that the broken objects do not stretch across more than one primitive.
To do this, all the exploded objects are taken. Their bounding boxes are checked against the bounding boxes of the primitives that exist in the primitive list. If the object's bounding box does not lie within the bounding box of any single primitive, it is a rogue object.
Once all the rogue objects have been identified, each of these rogue objects are taken and the bounding box of their individual faces are checked against the primitives from the primitive list. Sets of faces that lie within a single primitive are taken and detached into separate objects. At the end of this process, each detached object falls into a single primitive.
Once the objects are broken down, each of these objects is taken and applied mapping co-ordinates on the second channel. The second channel is used because the first channel has already been used to map the diffuse texture.
At this point, I would like to thank Simon Feltman for the wonderful plugin Multimap.dlx, which he has written. Without this plugin, I wouldn't have been able to access the 2nd channel mapping co-ordinates in Max.
In order to apply mapping co-ordinates to the objects, first the face normal of each object is determined. The script then checks which world axis is closest to the face normal (this will be referred to as the 'closest axis'). After checking, the object is applied with mapping co-ordinates perpendicular to this closest axis. Following this, the mapping co-ordinates are fit to the object extents, such that the mapping co-ordinates extend from 0 to 1. The code for this appears in Listing 4 and Listing 5.
Once the object is mapped with mapping co-ordinates on the 2nd channel, the right sized bitmap has to be created and assigned to the object as a lightmap. For do this, the area covered by the object along the closest axis is determined. This area is scaled by a factor to get the size in pixels. We'll term this as the object area in pixels. The user, while building the mesh, can set this factor. It gives the user control over the resolution of the lightmaps that are created. A large factor will yield small lightmaps and a small factor will result in large lightmaps.
The bitmaps that are created have to be square, and their height and width in pixels have to be a power of 2. The script then checks for the optimal bitmap size starting from the smallest in order to accommodate the 'object area in pixels' that was previously calculated. The code can be found in Listing 6.
Now the right bitmap size has been ascertained and the object has been mapped on the 2nd channel. However, the object mapping extends from 0 to 1, i.e. the entire bitmap area, whereas the actual object area in pixels, occupies only a region in that bitmap. Therefore, the mapping co-ordinates are scaled to fit the 'object area in pixels' in proportion to the bitmap size that has been ascertained.
After scaling the mapping co-ordinates, a new material is assigned to the object, which is basically a copy of the original material that the object had. The newly created blank bitmap is assigned as the lightmap by assigning it to the selfillum channel of the object's material. The set of objects at the end of this process will have proper textures and lightmaps mapped and assigned to them. Since every broken object gets a lightmap, it results in a huge number of lightmaps being used by the entire level mesh. Hence, these individual tiny lightmaps have to be clubbed into bigger ones.
To club these lightmaps, a proper criterion has to be determined and used. The script uses the original primitives that the objects resulted from in order to club their lightmaps. By doing this, the number of lightmaps should be equal to the number of primitives that were used to build the level mesh. The user also has the control to club them further by assigning a common ID to sets of primitives. The user can assign a user property called ClubID to the primitives before the export world is built. Sets of primitives are assigned a common ClubID value. When the script clubs the lightmaps, it also checks for primitives with the same ClubID and clubs the lightmaps of the objects accordingly.
To do all this, the objects have to first be linked to the primitives that they resulted from. Each primitive is taken, and all the export objects are checked against the primitive. The bounding extents of each export object is taken and checked if it falls entirely within the bounding extents of the primitive. Though this produces good results, some objects resulting from primitives that in turn fall totally into a bigger primitive, could be linked to the bigger primitive.
To avoid this, all the planes in the primitive are taken and collected into an array or collection called arrplanes. Then, the first face of each of the export objects, which fall into the primitive's bounding box, are taken and checked against the planes in the arrplanes array. The object has resulted from that primitive, if all three vertices of this face lie on any of the planes in the arrplanes array. Such objects are assigned the CreateID user proper of the primitive. This is because any object that was created by breaking the parent or parent2 should have faces which lie on one of the planes of the primitive that they resulted from. The code for this appears in Listing 7.
Once the links are completed, the script begins clubbing the lightmaps. A script function for clubbing these lightmaps has been made. When called, the function automatically determines the correct clubbed bitmap size for the lightmaps to be clubbed locates and remaps the mapping co-ordinates to vacant areas in the clubbed map and also reassigns the selfillum map of the object with the newly clubbed lightmap. It also provides a one-pixel edge to these mapped areas to accommodate bleeding from adjacent pixels, which is caused when bilinear filtering is used in the engine.
Once the clubbing is complete, the mesh is ready for export. All the objects are then grouped into one group called Parent_world_for_export, so that it is easy to select the group and export into the game engine. In case the user needs to modify or extend the game level, all they have to do is delete the group, unhiding all the primitives and the parent object that are currently hidden, and work on with the level mesh.
In addition to this some other properties are also set on these export objects such as ClubID and Ambient colour, which are all derived from the Primitives that they fall in.
The simple-object plugin is used to create scripted primitives, for use in building level meshes. A detailed description on this can be seen in Max script help under the topic Scripted simple-object plugin. It covers the creation of such scripted objects in detail.
The tower_plugin definition that can be found in the Max script help has been modified to build the custom primitives. The 'Stairs with railing' that is shown in figure3 is one such custom primitive.
Figure 3: A custom primitive 'Stairs with railing' with a bend modifier applied.
These custom primitives are very useful, and can give you good results when used with other Max modifiers.
Advantages and Disadvantages of this System
With this system in Max, one can build a level mesh with the ease associated with CSG modeling. If you compare this to building low poly level meshes in Max using loft or actually building faces, this method is far easier to create and more flexible to changes. Because the user makes the world using CSG primitives or brushes, he can easily make any changes to the primitives at any point of time. The resulting level mesh is built by script, and therefore cuts out manual work significantly.
All properties are stored against the primitives. Applying materials and mapping texture co-ordinates, are all done on the primitives. Hence, when the level mesh is built no property or work is lost. Moreover, since we are working inside Max, we can use Max modifiers like Bend, Taper and Skew on the primitives. Max modifiers are easy to use, and some of them may not exist in other CSG builders.
Here are some of the disadvantages or areas of improvement in the present system:
- The script currently depends on Max booleans while building the world from primitives. Therefore, it does not have complete control over the creation of the actual faces and vertices. Because of this, later on while building the export-mesh, the script has to check and link back the objects for export with the primitives.
- As the level mesh increases in size, building the export-mesh takes more time, especially when you want to club the tiny lightmaps into larger ones. Although this is faster than creating and mapping lightmaps manually, it could help speeding it up a bit.
- Though mapping changes to the primitives are reflected immediately, new materials assigned to these primitives are not seen on the world mesh until you build the world.
- Sometimes, some faces in the built world get wrong mapping co-ordinates while booleaning.
- Max script has no access to some elements inside Max. Two things that would have helped the existing script while building the world, are access to the vertex weld threshold spinner and the explode threshold spinner. Presently, the script file takes the threshold that was last set by the user inside Max. It can't set the threshold to a set value.
A script or plugin to generate the world mesh through BSP instead of Boolean would probably have more control over the actual faces and vertices being built. That should take care of many problems. With a little more control given to Max script over the elements inside Max, the user could get scripts that are more accurate.
Advantages of Building Levels in Max4 or GMax
When we started developing our own engine, we soon realized that we would need our own level editor. We were already using Max to create and export our meshes into our engine using custom plugin exporters. Since making our own editor would involve a lot of time and manpower, we turned to adapting Max to meet our level building needs. At this point, I must say that it has proved to be worth it. Eventually, we had our set of custom plugins and Max scripts to build game levels in Max, which met our engine specifications.
I may be a little biased towards Max since I have been a Max user since the 3D studio 2 days. However, I sincerely feel that Max is a very powerful tool, for not only building high poly scenes and characters, but also for building low-resolution level meshes.
To start with, if you are looking to make Max your game level builder, you don't have to start from scratch. You can select and use all the existing features in Max. For instance, the basic Max UI itself is ready to use. The Max UI moreover is totally customizable. You can write custom plugins and scripts, which a user can build game levels that are tailored for your game engine.
Features like object-user-properties that Max already has are very useful, and can be used extensively. The Unwrap UVW in Max is another widely used tool. When mapping low polygon meshes, it gives you total control over its mapping co-ordinates. Max actually let's you work on meshes at the basic vertex or texture vertex level, thereby giving you total control over the mesh that you are creating.
There are so many features in Max that can be used or adapted to meet your needs, such as material properties for surfaces in your level mesh, splines and bezier patches, which can be used if your game engine supports bezier patches. For instance, we used bezier splines to create and export camera paths and character paths into our engine. I couldn't possibly list all the features in Max that are available because it's too vast and its utility would vary depending on ones needs.
I have not had the opportunity to work on Max 4 or GMax until now. But from the all the reviews about it, that I have been closely observing, I can see lots of goodies in store that can be put to good use. First, the viewports claim to be WYSIWYG. One of the reasons for not lighting the mesh within Max itself was that the Max viewports could display only one texture at a time. With support for multiple textures, it could probably display textures as well as lightmaps at the same time. Since it uses DirectX8,you should be able to blend or modulate textures. You could even push other texture layers like noise and stains into the viewport display inside Max. This would get the display within Max close, if not similar to what you would see in your own game engine using DirectX8.
At present, we have our own fire and smoke particle systems. We can bring it all into Max, since Max viewports now support true transparencies. This huge enhancement in the Max viewport display itself is a big reason to consider using Max or GMax as your level editor.
Another feature that draws attention are object attributes. You can now set your own custom attributes for objects, thereby making them unique and capable of fitting the specifications that you want for your game engine. Although you can use object-user-properties to set and retrieve properties on objects, custom-object-attributes would permanently attach these attributes to every object in your scene.
Another important feature is ability to set individual properties for every face in your level mesh. For instance, the existing script can now link and store information against each face to the primitive that it belongs to, without actually breaking them apart.
Defining your own brushes is yet another big feature in Max4. A CSG level builder, for example, can make use of this feature to define and work on its own custom brushes. With DirectX8, it even supports per pixel and vertex lighting inside the Max viewports.
GMax is a subset of Max4 with a feature list that suits building game levels. Therefore, it should be a much smaller program that focuses towards building game levels. Licensed programmers will be able to define their own file types in addition to custom functions and plugins. Moreover, since GMax will be freely downloadable a game player can download your game module and run it on GMax to edit existing game levels, or to create new levels for your game.
hundreds of new features and enhancements in Max4 and GMax. The features
that I have discussed are significant for game level editing, which I
feel can be put to immense use if you plan on using Max4 or Gmax. There
is so much in store included in the new version of Max and Gmax, and it
is up to us to adapt and use these features to their fullest.