Sponsored By

Creating Procedural Objects in 3D Studio MAX

A procedural object is created by an algorithm that takes a set of parameters that define the object and produces the object representation from that set. It is possible to create procedural objects inside 3D Studio MAX to get data into your game engine since a procedural object in MAX can store information other than whats required for the visual representation. In this article, Gurjeet Sidhu, lead programmer at Dhruva Interactive explains how to write a plug-in to create procedural objects in 3D Studio MAX 3.1.

Gurjeet Sidhu, Blogger

May 5, 2000

39 Min Read
Game Developer logo in a gray background | Game Developer

What's a Procedural Object and Why Would You Want to Create One?

A procedural object is created by an algorithm that takes a set of parameters that define the object and produces the object representation from that set. For example, we can write a procedure to create a box, as follows. Given the center, the width, and the height, the procedure generates a list of faces and vertices that constitute the mesh representation of the box. We could also create the box mesh manually by specifying the vertices and faces, but that's very inconvenient. In many cases it is necessary to create the object procedurally; for example, it would be very tedious to create a sphere by explicitly specifying vertices and faces.

It is possible to create procedural objects inside 3D Studio MAX to get data into your game engine since a procedural object in MAX can store information other than whats required for the visual representation. For example, suppose you want to specify certain locations in the level which you will use in your code to place characters and objects. You could create a Location object that stores the position and an id number. The designer can create various instances of Location objects within the level and assign a unique id to each; this data can then be exported into a file and used by the game engine. Or you could create a grid for pathfinding information and assign attribute values to each square - occupied, free etc.

In this article we will see how to write a plug-in to create a simple procedural object in Max 3.1. In order to cover the basics and provide a starting point for programmers to create their own procedural objects, we'll focus squarely on the MAX SDK and won't get involved in the intricacies of creating complicated objects like teapots and ringwaves. We'll stick to the bare minimum required to write a functional procedural object plug-in for the most part and after that approaches to advanced techniques will hopefully be clear. We'll also try to avoid getting into discussions of theoretical aspects; I'd rather just tell you where to look in the MAX SDK help file to find a detailed treatment of the topic. The code for a demo demonstrating the concepts found in this article can be found here.

Since it's not much use creating this kind of procedural object if you can't use it in your game engine, we'll take a quick look at how to export them.

So what object should we create? The object should be simple so people who want to create other kinds of objects won't get bugged by details they see as irrelevant, but it should be complex enough to examine all the basic concepts related to procedural objects. A portal object should fit the bill. Our portal object will be defined by four vertices on a plane, a source cell, and a estination cell, and could be used to implement a simple portal-culling scheme. In our example, the cells will be the standard MAX boxes. If you don't know about portal culling you can read up on it at the Flipcode site (http://www.flipcode.com). In any case it's not all that important; we'll just see how we'd go about creating an object with the above-mentioned properties without worrying about how to use it in a real-world application.

Preliminaries

I'll assume that you're familiar with the basics of MAX itself, if not the SDK. Any previous experience of creating plug-ins of any type would be very helpful. We'll keep things simple so you should be able to follow most of the stuff even if you've had no exposure to the MAX SDK before this.

We'll need the following

 

  • Visual C++. I'm using 6.0 but 5.0 should work fine

  • 3D Studio MAX 3.1 and the SDK

 

One of the best sources of information about the MAX SDK is the help file and we'll often look to it for details, so it's a good idea to keep it open in the background while reading this article. Now is a good time to open it and bookmark the first page, the one titled 'Plug-In Software Development Kit'; all references will be relative to this page. Also keep in mind that to look up a method, we need to type 'methods, MethodName' in the help file search index and to look up a class we need to type 'class ClassName'.

Plug-in Basics

All MAX plug-ins are Windows DLLs and are usually located in the 'Stdplugs' and the 'Plugins' directories. Typically, 'Stdplugs' contains the plug-ins that come along with MAX and 'Plugins' has third-party plug-ins that you download from the Net or write yourself. There are various categories or types of plug-ins that can be created using the MAX SDK; procedural objects is one of them. We could also create, among others, plug-ins to import/export files, system plug-ins like Biped, object modifiers like Bend and Taper, and atmospheric plug-ins for effects like fog. For a complete list, look in the 'Plug-In Types Overview' section of the help file. There is a standard filename extension associated with each type which is used by MAX when it is searching for plug-ins to load. Procedural object plug-in DLLs have the filename extension DLO. File export plug-ins have the extension DLE, utility plug-ins have the extension DLU and so on. For a complete list look under 'Writing Plug-In Applications / Plug-In Directory Search Mechanism'

The MAX SDK is a set of classes that developers can combine and extend to create plug-in applications. There is an elaborate class hierarchy and the idea is that we tap into it at the right places to get our job done. You can have a look at this hierarchy under 'Plug-In Architecture Overview'. To write a MAX plug-in we'd start by identifying what type of plug-in it should be. Based on this, we'd derive a class for our plug-in from one or more of the classes in the SDK. This would give us certain inherited virtual functions that we would need to implement in our class. These functions would be called by MAX at the appropriate time. For example, if we wanted to do file export, we'd derive from the class SceneExport and implement the function DoExport() which would be called by MAX when the user asked to export the scene. Once we have control, we have access to all kinds of scene information and can call functions implemented inside MAX to do our stuff.

We are real programmers and we want to get right down to the metal, so we won't use any MFC or appwizards to create our plug-in. It's not very complicated. We create a new project of type Win32 Dynamic Link Library / Empty DLL Project, add our source code and the required MAX lib files to the project and build it. We must tell the VC++ IDE to look for header files in the MAX SDK 'Include' directory as well. And we must make sure that the DLL has the DLO extension and ends up in the MAX 'Plugins' directory.

One additional thing to keep in mind is that MAX requires us to add a DEF file to our project. If we don't do this, MAX won't load our plug-in. The DEF file looks like this.

LIBRARY PortalObj
EXPORTS
LibDescription @1
LibNumberClasses @2
LibClassDesc @3
LibVersion @4
SECTIONS
.data READ WRITE

You can use this DEF file for all your plug-ins; just keep changing the library name.

The Plug-in's Interface to MAX

Every MAX plug-in must implement a set of functions that are called by MAX when the plug-in is first loaded. These functions provide MAX with information about the plug-in and are called LibNumberClasses(), LibClassDesc(), LibDescription() and LibVersion(). For an explanation of these functions look under 'Writing Plug-In Applications / DLL Functions and Class Descriptors'.

We get a default DllMain() when we build a DLL using VC++ but we implement our own anyway because we need to do some initialization at startup.

Our implementation of these functions is shown in Version1.cpp. We've created a do-nothing PortalObj class that will be our main plug-in class and we'll fill it up as we go along. We've written our own DLLMain() so that we can save the DLL instance handle.

Let's see what we've accomplished with this code. Actually this is enough for us to make our presence felt in MAX. At this point we can create a project workspace and add Version1.cpp to it. We need to add the following lib files from the SDK 'Lib' directory: maxutil.lib, geom.lib, core.lib and mesh.lib. We need to add comctl32.lib if it's not already there, and also the DEF file. Now we can build the first version of our plug-in. Next, we startup MAX, go to the Create branch of the command panel, select Geometry (the default), click on the drop-down box that says Standard Primitives and we see the new entry Portal. However, don't try to create a portal right now or else MAX will crash because our PortalObj class is still a dummy and doesn't implement all the functions required.

The User Interface

Now that we know what it takes to make MAX acknowledge the existence of our plug-in, we'll create our user interface. The user will create the portal by clicking and dragging with the mouse in one of the viewports in the same way you'd create a box or a sphere so we don't really need any specific user interface elements for the creation phase, but after that he'll want to select the source and destination cells. We'll create a rollup page to facilitate this and add it to the command panel. The page is pretty simple and looks like this:

sidhu_01.GIF

The way it works is as follows. To select a source or destination cell, the user can click on the appropriate button. This action will place him in Pick mode after which he can click on a node in one of the viewports to select it as the source or destination cell. We can throw in a check to make sure that the node he selects is a box. If all is OK, we'll display the name of the selected node in the appropriate edit box.

A rollup page is simply a dialog box created using the resource editor. We must make sure it's exactly 108 pixels wide. I guess that's because it needs to fit nicely into the command panel. When you create the dialog box, in the Dialog Properties, go to the Styles pane and set the style to Child and the border to None. In the More Styles pane tick the Visible check box. We can use the defaults for everything else. Then we add the static text, the two edit boxes, the two buttons and we're done.

The rollup page is now ready to be added to the command panel. How do we do this? It so happens that whenever a procedural object is being created or modified MAX calls a couple of functions inside the plug-in so we'll know what's going on. These functions are BeginEditParams() and EndEditParams(), declared as virtual functions in class Animatable which is right at the top of the class heirarchy shown in 'Plug-In Architecture Overview'. We'll be deriving our plug-in class from a class that is in turn derived from Animatable so we'll inherit these functions as well and override them. When our BeginEditParams() gets called, we'll add the rollup page to the command panel using the AddRollupPage() function and when EndEditParams() gets called we'll delete it by calling DeleteRollupPage().

We need to implement a DialogProc function for our rollup page the same way we'd do it for an ordinary dialog box in a Windows application.

The code that includes all this is shown in Version2.cpp. The rollup page is in Resource.rc. For the time being, we've derived our class straight from Animatable and our DialogProc just returns TRUE in response to the WM_INITDIALOG message. We add the resource file with our rollup page to the project and use the code in Version2.cpp to get the next version of our plug-in.

If we now click on the Portal button in the Create branch of the command panel we can see the rollup page. Almost anything we do after this, including moving the mouse pointer in a viewport will cause a crash for the same reason as before and it's about time we did something about it. We need to put in the code required to create the portal in a viewport after which we can come back to the DialogProc, add functionality to the user interface controls and start picking source and destination cells.

The portal needs to maintain pointers to the source and destination cells. We could store this information in class variables, but there are some issues to be considered. For example, say we've assigned a node as the destination cell to a portal and we store a pointer to the node in a class variable. We can't write the pointer to the disk when the user saves the file, so we'd have to save something like the node name instead. Then when the file is next opened, we'd have to search for the node by name and initialize our pointer again. That's not so bad but consider another problem. What happens if the node is deleted? We end up with an invalid pointer.

We have a simple, clean solution to these issues in MAX's concept of References. Let's get a handle on this next.

References

References in MAX, not to be confused with C++ references, are used to keep track of dependencies between scene elements.

The following are excerpts from the help file.

"A reference is a record of dependency between a reference maker and a reference target. The reference maker is said to be dependent upon the reference target. If the target changes in some way that affects the maker, the maker must be notified so it may take appropriate action."

"The system takes care of loading and saving references when an object is saved to disk. An object does not need to explicitly save its references, nor does an object need to load its references. After a scene is loaded, an object's references will automatically be restored."

This is just what we need. In our case the portal is a reference maker and the destination cell node is a reference target. Let's see how to use references in our plug-in.

If we want to create references to anything in our plug-in class we have to derive it from the MAX SDK class ReferenceMaker. We are currently deriving from Animatable but since ReferenceMaker is derived from Animatable as well we'll just subclass PortalObj off ReferenceMaker instead. We're moving on up in the class hierarchy.

For a scene element to be a reference target, it must be derived from the class ReferenceTarget. That's OK too, because nodes are an instance of the INode class which is subclassed off ReferenceTarget. You can look at the class hierarchy diagram now to get your bearings if you haven't already done it.

We still need to store pointers to the source and destination cells but we'll be using references to manage them. We begin by declaring class variables.

INode *src, *dest;

Each pointer will be associated with a reference and we'll use ID numbers to distinguish between them. How we will use these IDs will become clear very soon. So we next define IDs.

#define ID_SRC_REF 0
#define ID_DEST_REF 1

The portal then creates a reference to the node using the function MakeRefByID(). If it's a source node, we pass ID_SRC_REF as a parameter and if it's a destination node we pass ID_DEST_REF. The code looks like this

portal->MakeRefByID(FOREVER, ID_DEST_REF, node);

Notice that FOREVER. It's supposed to indicate the interval of time over which the reference is valid in case you're doing an animated sequence but in MAX 3.1 plug-ins can't pass any other value anyway so we won't worry about it.

When you call MakeRefByID() MAX responds by calling another function that we've inherited from ReferenceMaker, called SetReference(), with a pointer to the reference target so we can store it. Our implementation of SetReference() looks like this.

void PortalObj::SetReference(int i, RefTargetHandle rtarg)
{
switch (i)
{
case ID_SRC_REF: src = (INode*)rtarg; break;
case ID_DEST_REF: dest = (INode*)rtarg; break;
}
}

So far we've just taken a rather roundabout route to assign a pointer to a variable. But it's going to be worthwhile because we've inherited another function from ReferenceMaker called NotifyRefChanged(). This will be called by the reference targets whenever they are changed in any way that can affect us and we'll get a pointer to the reference target so we can find out which one was changed and decide what to do about it.. We'll use it to detect if the source or destination node has been deleted and if it has, we'll set the corresponding pointer to NULL.

RefResult PortalObj::NotifyRefChanged(Interval changeInt, RefTargetHandle hTarget, PartID& partID,
RefMessage message)
{
// see if src or dest was deleted
if (message == REFMSG_TARGET_DELETED)
{
if (hTarget == src) src = NULL;
else if (hTarget == dest) dest = NULL;
}
return REF_SUCCEED;
}

Next we need to think about file loading and saving. Actually, it's already done. The system saves all references automatically and when the file is opened again, it'll call SetReference() with pointers to the nodes so our variables get initialized.

To complete the picture, we need to implement two more functions of ReferenceMaker for the system to use. These are NumRefs() and GetReference().

int PortalObj::NumRefs()
{
return 2;
}

RefTargetHandle PortalObj::GetReference(int i)
{
switch (i)
{
case ID_SRC_REF: return src;
case ID_DEST_REF: return dest;
}
}

In NumRefs(), should we return the number of references we actually have created already or the maximum number we're planning to create? The answer is: the latter, otherwise problems will occur. This is because the system handles our references as a virtual array that is 0-based like in C++. So valid indexes for the system are 0 to NumRefs() - 1. For example, suppose the user creates a portal, assigns a destination cell only and then saves and closes the file. We've created only one reference, but if we return 1 in NumRefs(), MAX will only care about the 0th reference. It won't call SetReference()with ID_DEST_REF when it opens the file again and our dest variable won't get initialized. If we return 2, on the other hand, our SetReference() will be called only once, with ID_DEST_REF as a parameter and that's how it should be. In any case, SetReference()won't be called with ID_SRC_REF, because the 0th reference doesn't exist.

For the complete low-down on references, look under 'Must Read Sections for All Developers / References'.

Now that we've taken care of the source and destination cell pointers, we can shift our attention to the geometry data. The portal mesh consists of four planar vertices and two faces. To generate this, we'll need to get some information from the user during the creation phase in the viewport. For example, we could store the opposite corners of the rectangle formed by the four vertices. This data is not subject to the problems faced by the pointers so we could simply use class variables without creating any references. For now we'll assume that this is what we're going to do, but there are wheels within wheels, so we'll end up storing the geometry data in a slightly different way.

The Creation Phase in the Viewport

We want to let the user create a portal by clicking and dragging with the mouse in one of the viewports in much the same way as he'd create a box or a sphere. We need to provide MAX with a function to call so that it can let us know what the user is doing with the mouse. MAX gives us information about mouse events like MOUSE_POINT, MOUSE_MOVE, MOUSE_DBLCLICK etc and the position of the mouse pointer. How we use this information is up to us.

To write our mouse handler, we create a class derived from the MAX SDK class CreateMouseCallBack, thereby inheriting a pure virtual function called proc() that we will implement and MAX will call. Let's call this class PortalObjMouseHandler; it's a new class and quite distinct from PortalObj, our main plug-in class. Now how do we let MAX know about PortalObjMouseHandler? It so happens that in the class BaseObject, derived from ReferenceTarget, there is a function called GetCreateMouseCallBack() that MAX calls to find out about the plug-in's mouse handler. So we move up further in the class hierarchy and derive our main plug-in class from BaseObject instead of ReferenceMaker, thereby gaining the ability to handle mouse input along with our existing capabilities of displaying our user interface and creating references. All is well, and we can now return a pointer to an instance of the class PortalObjMouseHandler in our implementation of GetCreateMouseCallBack().

While creating our portal, we'll be working in a coordinate system centered at (0, 0, 0) in the viewport with the +ve X axis to the right, the +ve Y axis upwards and the +ve Z axis coming out of the screen towards us. I don't know what this coordinate system is called in MAX so in this article we'll call it as Csx. Csx is independent of the view chosen for the viewport and is not necessarily aligned with the coordinate system displayed near the bottom left corner. This is why, for example, when creating a teapot we're always looking at it from above in the viewport we are creating it in. The exceptions to this are the User and the Perspective views, in which case the points are in the coordinate system shown in the bottom left corner.

We'll create our portal in the XY plane in a local coordinate system originating at the center of the rectangle. At the same time, we'll fill in a translation matrix that will contain the position of the center point with respect to the Csx origin. MAX will use this matrix to display the portal at the correct position in the viewport. We'll click and hold down the left mouse button to define any one corner of the rectangle, drag, and let go to define the opposite corner. The rectangle will always be created facing us; we can then move and rotate it to position it as desired within the scene. The rectangle parameters we'll store are half the height and half the width and MAX will store the transformation matrix required to display the portal correctly in the scene.

The code for our mouse handler is shown in Listing 1. The fifth parameter, IPoint2 m, is the position of the mouse pointer in Csx and the sixth, Matrix3& mat, is the matrix we set to the initial translation required to display the portal at the correct position in the viewport. There's also some stuff related to viewport snapping. For details, look for the description of the class CreateMouseCallBack in the help file. Remember that to find a method in the help file, you've got to type 'methods, MethodName' and to find a class you've got to type 'class ClassName'

Listing 1. The Mouse Handler

// assume that halfHt are halfWd are PortalObj class
// variables

int PortalObjMouseHandler::proc(ViewExp *vpt,int msg, int point, int flags,
IPoint2 m, Matrix3& mat)
{
Point3 p1;

if (msg == MOUSE_FREEMOVE)
vpt->SnapPreview(m,m,NULL, SNAP_IN_3D);

if (msg==MOUSE_POINT || msg==MOUSE_MOVE)
{
switch (point)
{
case 0:
p0 = vpt->SnapPoint(m,m,NULL,SNAP_IN_3D);
mat.SetTrans(p0);
hlfHt = hlfWd = 0.0f;
break;

case 1:
p1 = vpt->SnapPoint(m,m,NULL,SNAP_IN_3D);

halfHt = (p0.y - p1.y) / 2.0f;
if (halfHt < 0) halfHt = -halfHt;

halfWd = (p0.x - p1.x) / 2.0f;
if (halfWd < 0) halfWd = -halfWd;

mat.SetTrans((p0 + p1) / 2.0f);

if (msg == MOUSE_POINT) return CREATE_STOP;

break;
}
}
else if (msg == MOUSE_ABORT) return CREATE_ABORT;

return CREATE_CONTINUE;
}

 

A Matter of Class

So far, we've been working our way up the class hierarchy based on what functionality we wanted to add to our plug-in. We started with Animatable and have now reached BaseObject after a brief halt at ReferenceMaker. I guess it's time I came clean and told you that we're got some way to go still. We'll be deriving our main plug-in class from the MAX SDK class SimpleObject. Why? Mainly because the help file says so and that's the way it's done. We started at the base of the class hierarchy; actually you'd do just the opposite. You'd start by identifying what class you need to derive from based on your plug-in's type and then see what methods of the base classes you need to implement as well. The two places in the help file that serve as starting points for your journey are 'Plug-In Types Overview' and 'How To Create Various Plug-In Types'. At least for me, this is followed by a lot of relentless clicking as I wade through the documentation until light starts to dawn. The samples are another valuable source of information. For this plug-in, I started with Sphere_c in the Samples/HowTo directory. That's a comprehensive sample with around 1500 lines of code so I kept cutting out stuff until I got a working sample that was small enough for me to comprehend.

We need SimpleObject because, as the help file says, "Procedural object that represent themselves with a mesh may use this class. It provides implementations of many of the methods required to create a procedural object thus simplifying the developer job considerably."

Before we get on to creating the mesh and picking the source and destination nodes, we'll get back to the issue of storing our geometry data. Recall that at the end of the section on references, we said that we'd store this data in a different way because of certain wheels within wheels. Well, these wheels belong to SimpleObject. Enter Parameter Blocks.

Parameter Blocks

Parameter blocks are not really required for the simple plug-in we are writing. However they will surely crop up, especially in conjunction with Parameter Maps, when we move on to more complex applications so it won't hurt to explore this topic now, especially as they're quite simple to understand and use.

The parameter block is a mechanism for storing the values of a plug-in's parameters. We first create a description of the parameter block we want to create. This description consists of information about the number of data items we want to store and their types. We get to choose from a set of built-in types such as int, float and 3D vector. When we create the block using this description, MAX allocates memory for the data elements and we access individual items using indexes.

We'll use a parameter block to store our geometry data. It consists of two floating-point values, the half-height and the half-width. To describe the parameter block, we need an array of ParamBlockDesc objects, one for each data item. For each item, we first specify the type. The next value is always NULL and the third indicates if the item is animatable. Our parameter block looks like this.

static ParamBlockDesc pblockDesc[] =
{
{ TYPE_FLOAT, NULL, FALSE }, // index 0; half-height
{ TYPE_FLOAT, NULL, FALSE } // index 1; half-width
};

We create the parameter block using the CreateParameterBlock() function. We'll use the first element in the parameter block for the half-height and the second for the half-width. We can access our data by ID using the SetValue() and GetValue() functions. For a description of these as well as more information on Parameter Blocks check out 'Must Read Sections for All Developers / Parameter Blocks'.

The main reason we are using a parameter block is that SimpleObject expects us to. It has a variable, IparamBlock *pblock, that we've inherited. Now, BaseObject has a virtual GetParamBlock() function that the system will call to ask theplug-in for it's parameter block and the default implementation is to return NULL; SimpleObject overrides this and returns pblock. The problem with this setup is that pblock is a wild pointer that was not set to NULL by SimpleObject in it's constructor. The system thinks that it's got a valid pointer to our parameter block and presumably proceeds to use it which that brings us to a grinding halt. You can see the code for the class SimpleObject in simpobj.h in the SDK Include directory and simpobj.cpp in the Samples/ HowTo/Misc directory.

Of course, we can still keep our geometry data in class variables and not create a parameter block. All we have to do to prevent a crash is to set pblock to NULL in the PortalObj constructor. But since we've come so far we might as well continue. The parameter block class, IparamBlock, is derived from ReferenceTarget, so we can create a reference to it. This is again for the benefit of SimpleObject, which is waiting with it's implementation of the NumRefs(), GetReference(), SetReference() and NotifyRefChanged(). The SimpleObject version of the first three is given below.

int NumRefs() {return 1;}

RefTargetHandle GetReference(int i) {return pblock;}

void SetReference(int i, RefTargetHandle rtarg) {pblock=(IParamBlock*)rtarg;}

Note that we now need to call the SimpleObject versions in our implementation of NumRefs(), GetReference(), SetReference() and NotifyRefChanged(). We also need a reference ID for the reference to the parameter block.

Building the Mesh

We're now ready to build our mesh. This consists of filling in the BuildMesh() function that we've inherited from SimpleObject. We've also inherited the variable mesh that's an instance of the Mesh class. If you look at a description of this class in the help file you'll see quite a bit of stuff there, but we'll just initialize the vertex list and the face list.

Let's have a quick look at how the mesh is organized. Basically, it's a collection of faces with counter-clockwise winding. Each face has three vertices, each of which can have upto three elements of information, the 3D space coordinates, the texture coordinates and the vertex color. This information is stored in three separate arrays which are Point3 *verts, UVVert *tVerts and VertColor *vertCol respectively. To index into these, there are three arrays of Face objects, the class members Face *faces, TVFace *tvFace and TVFace *vcFace respectively.

So, to get all the information about the ith face in a mesh, we proceed as follows. The vertex coordinates are verts[faces[i].v[0]].x, verts[faces[i].v[0]].y, and verts[faces[i].v[0]].z. For a 2D UV texture mapping, the texture coordinates are tverts[tvFace[i].v[0]].x and tverts[tvFace[i].y. Finally the vertex colors are vertCol[vcFace[i].v[0]].x, vertCol[vcFace[i].v[0]].y and vertCol[vcFace[i].v[0]].z.

The code that incorporates all that we've discussed so far is given in Version3.cpp. The new features are that we're deriving our class from SimpleObject, we're building the mesh, we've got references, the parameter block, the mouse handler and last but not least, it doesn't crash any more.

Node Picking

Next we'll see how to pick nodes for the source and destination cells. The first step is to set the command mode to a standard pick mode. For example, if we create a box, select it, go to the Modify branch in the command panel, click on Edit Mesh and set the Selection Level to Vertex, we're in pick mode. To get to this mode, we need to call the MAX function SetPickMode(). We got a pointer to the class IObjParam in our BeginEditParams() method and we saved in the class variable ip. We can use this to call functions that are exported from the MAX executable; AddRollupPage() is one of them, SetPickMode() is another. To get an idea of what else can be done with this pointer, look up the class Interface in the help file. IobjParam is identical to this class.

Once we're in pick mode, MAX needs a function to call to let us know which node the user is clicking on. We had a similar situation when we were writing the mouse handler. To create a callback for node picking, we derive a class from the MAX SDK class PickModeCallback and pass a pointer to an instance of the derived class when we call SetPickMode(). We'll call this class PortalObjPickModeCallback. In this class, we have two inherited functions to implement: HitTest()and Pick().

HitTest() gets called when the user clicks with the mouse in one of the viewports. The system passes it a bunch of parameters like the viewport and the mouse position based on which we have to find out if a node was hit. Luckily for us, we can call the Pick() method of class Interface that will hit test the screen position for nodes and return an INode pointer if one was hit or NULL otherwise.

If HitTest() returns TRUE indicating that a node was selected, our Pick()method gets called. Here we find which node was selected, create a reference to that node, display it's name in the edit control and end the pick mode by returning TRUE.

The code that handles the node picking is given in Listing 2. Version4.cpp contains this code plus Version3.cpp. If you build it, you can see what is close to the finished version of the plug-in. A couple of problems still need sorting out, however. The first is that when you pick a node as the source or destination cell in the create pane of the command panel, the rollup page disappears and we find ourselves in the Move mode. For now, select the portal and go to the modifiers pane to pick the source and destination cells. Another problem is that any node can be chosen as the source or destination; a light, a camera or even the portal itself. Recall that we'd decided to put in a couple of checks to make sure that only boxes were assigned as source or destination cells. We'll get to that in the next section where we'll descend down the geometry pipeline into the murky depths of MAX.

Listing 2. Node Picking

// Class PortalObjPickModeCallback - for node picking

#define SRC_CELL 0
#define DEST_CELL 1

class PortalObjPickModeCallback : public PickModeCallback
{
public:

PortalObj *portal;
HWND hDlg; // dialog window
int cell; // which one are we looking
//for - src or dest

BOOL HitTest(IObjParam *ip, HWND hWnd,ViewExp *vpt,
IPoint2 m, int flags);
BOOL Pick(IObjParam *ip,ViewExp *vpt);
};

// the node picker object
PortalObjPickModeCallback g_pickModeCB;

// hit testing
BOOL PortalObjPickModeCallback :: HitTest(IObjParam *ip,
HWND hWnd,ViewExp *vpt,
IPoint2 m, int flags)
{
INode *node = ip->PickNode(hWnd, m);
return (node != NULL);
}

// assign source and destination cells
BOOL PortalObjPickModeCallback::Pick(IObjParam *ip,ViewExp *vpt)
{
// get the selected node
INode *node = vpt->GetClosestHit();

if (cell == SRC_CELL)
{
// display node name
Edit_SetText(GetDlgItem(hDlg, IDC_EDIT_SRC),
node->GetName());

// create ref to node
portal->DeleteReference(ID_SRC_REF);
portal->MakeRefByID(FOREVER, ID_SRC_REF, node);
}
else // cell == DEST
{
Edit_SetText(GetDlgItem(hDlg, IDC_EDIT_DEST),
node->GetName());

portal->DeleteReference(ID_DEST_REF);
portal->MakeRefByID(FOREVER, ID_DEST_REF, node);
}

return TRUE;
}

// the UI handling stuff

BOOL Cls_OnInitDialog(HWND hwnd, HWND hwndFocus, LPARAM lParam)
{
PortalObj *portal = (PortalObj*)lParam;

// save a ptr to the portal
SetWindowLong(hwnd, GWL_USERDATA, lParam);

// init src and dest displays

if (portal->src) Edit_SetText(GetDlgItem(hwnd, IDC_EDIT_SRC), portal->src->GetName());
else Edit_SetText(GetDlgItem(hwnd, IDC_EDIT_SRC), "");

if (portal->dest) Edit_SetText(GetDlgItem(hwnd, IDC_EDIT_DEST),portal->dest->GetName());
else Edit_SetText(GetDlgItem(hwnd, IDC_EDIT_DEST), "");

// init pickmode data
g_pickModeCB.portal = portal;
g_pickModeCB.hDlg = hwnd;

return TRUE;
}

void Cls_OnCommand(HWND hwnd, int id, HWND hwndCtl, UINT codeNotify)
{
PortalObj *portal;

switch (id)
{
case IDC_SRC:

g_pickModeCB.cell = SRC_CELL;

portal = (PortalObj*)GetWindowLong(hwnd, GWL_USERDATA);
portal->ip->ClearPickMode();
portal->ip->SetPickMode(&g_pickModeCB);

break;

case IDC_DEST:

g_pickModeCB.cell = DEST_CELL;

portal = (PortalObj*)GetWindowLong(hwnd, GWL_USERDATA);
portal->ip->ClearPickMode();
portal->ip->SetPickMode(&g_pickModeCB);

break;
}
}

BOOL CALLBACK DialogProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
BOOL processed = TRUE;

switch (uMsg)
{
HANDLE_MSG(hwnd, WM_INITDIALOG, Cls_OnInitDialog);
HANDLE_MSG(hwnd, WM_COMMAND, Cls_OnCommand);

default: processed = FALSE; break;
}

return processed;
}


A Short Trip Down the Geometry Pipeline

This is a complicated topic that I don't claim to know too well myself so we'll just have a brief overview here. The geometry pipeline system is what allows a node in the scene to be altered, perhaps repeatedly, through the application of modifiers. To see this in action, create a portal, choose the Edit Mesh modifier, set the Selection Level to Vertex, click on a corner of the portal and drag it to deform the portal. How is this deformation happening? After all, we didn't take care of this kind of thing in our mesh building code. What actually happened was this: when we created our portal, MAX created a node for it and it became the Base Object at the beginning of the pipeline. That's where it's still exists unaltered. To verify this, click on the Edit Stack button in the Modifier Stack rollup page and you'll see that the Edit Mesh modifier has been added to our node's pipeline. If you now delete the modifier by selecting it and saying Cut, you can see the portal in it's pristine form. Alternatively, you can say Collapse all, in which case the base object and the results of applying the modifier will get fused into one giving us an Editable Mesh. In that case we can say goodbye to the portal as we knew it cause we'll never see it again.

What we see in the viewport and what gets rendered is the result of evaluating the entire pipeline with the output of each stage being the input to the next. The final result is called the world space state of the object. Let's see how we'll use this information. In our plug-in, we need to check if a given node is a box simply by seeing if it has eight vertices and twelve faces. This test is rather primitive but will do for now. The first step is to evaluate the node's pipeline and get it's world space state. This is done by calling the node's EvalWorldState() function to get an instance of the class ObjectState which contains a pointer to the object that appears in the scene. To make sure the node's not a light or a camera, we check to see if it can be converted to a triangle mesh by calling the object's CanConvertToType() method. If it can be converted, we ask it do so, after which we can access the mesh and count the number of vertices and faces it has. The code that does this is given in Listing 3.

Listing 3. The Nodes Check

// return TRUE if the node is a box
BOOL CheckNode(INode *node)
{
ObjectState os;
Object *obj;
TriObject *tri; // represents a triangle mesh object
BOOL result = FALSE;

// evaluate node world space state and get a pointer
// to the object this node represents
os = node->EvalWorldState(0);
obj = os.obj;

// see if the object can be converted to a triangle // mesh
if (obj->CanConvertToType(Class_ID(TRIOBJ_CLASS_ID, 0)))
{
tri = (TriObject *) obj->ConvertToType(0,
Class_ID(TRIOBJ_CLASS_ID, 0));

if (tri->mesh.numVerts == 8 && tri->mesh.numFaces == 12)
result = TRUE;

// if the system gave created a new object for // us, then delete it now that we're finished / // with it
if (obj != tri) tri->DeleteMe();
}

return result;
}

There is a detailed discussion of the geometry pipeline under 'Must Read Sections for All Developers / Geometry Pipeline System'. You can read up on nodes and object modification in the Advanced Topics section. I found it heavy going on the whole but the good part is that we don't really need to know too much to be able to get our job done. In fact the little we've discussed will take us far. For example, we'll see in the next section this is about all the information we need on this topic to export geometry from a scene.

Finally, let's see what to do about the other problem that we had, the disappearing rollup page. It says in the documentation for the PickModeCallback::Pick() method that returning TRUE will set the command mode to MOVE so I don't see that we can do much about it. Instead, we'll cunningly remove the rollup page from the Create pane altogether and ensure that it only appears on the Modifier pane. If you look at the documentation for the function Animatable::BeginEditParams(), you'll see that the flags parameter indicates which branch of the command panel the user is in and none of the given values is for the Modify branch. So we'll create a rollup page only if flags is zero.

The latest and greatest version of the plug-in code with the nodes check and the sleazy hack for the rollup page is given in Version5.cpp. That's our procedural object plug-in.

What's Next

How do we export our procedural object? We'd have to write an export plug-in for that and it would take too long to discuss the procedure in detail here, but we a quick overview is possible. To write an export plug-in, we need to derive our main plug-in class from the MAX SDK class SceneExport and implement a bunch of inherited pure virtual functions. One of these is DoExport(), which is called when the user asks to export the scene into our file format. DoExport() has a parameter that is a pointer to an instance of the class ExpInterface, derived from IScene, which can be used to enumerate all the nodes in the scene. The function to do this is IScene::EnumTree(). We create an enumeration callback which is called for every node in the scene. Inside this callback, we need to see if the node we got is a portal and, if so, proceed to export it. One simple way of doing this is to see if it's name begins with the string "Portal". If it does, we can convert it to a TriObject and get the geometry data. Next we need to get the source and destination cell pointers. For that we get the pointer to the object in the scene that the node references and typecast it to a pointer to an instance of the PortalObj class, after which we can access the class variables src and dest. Part of the code for the callback is shown below.

int NodesCallback: callback(INode *node)
{
ObjectState os;
Object *obj;
TriObject *tri;

// evaluate node state
os = node->EvalWorldState(0);
obj = os.obj;

// check node name
if (_strnicmp("Portal", node->GetName(), 6))
return TREE_CONTINUE;

if (obj->CanConvertToType(Class_ID
(TRIOBJ_CLASS_ID,0)))
{
tri = (TriObject *) obj->ConvertToType(0,
Class_ID(TRIOBJ_CLASS_ID, 0));

// export the geometry data here

// get a PortalObj pointer to access the source // and destination cells
PortalObj *portal = (PortalObj*)node->GetObjectRef();

// export source and destination cells here

if (obj != tri) tri->DeleteMe();
}

return TREE_CONTINUE;
}

When exporting the geometry, remember that the vertex coordinates will be in the local coordinate system. The matrix to convert them to world coordinates can be obtained by calling the node's GetObjectTM() function. There's also a INode::GetNodeTM(), but that returns the transformation matrix for the node's pivot, because in MAX the node's pivot and the geometry can be moved around independently of each other.

You might want to add another rollup page with spinner and edit controls so that the user can interactively adjust the creation parameters. This can be done using MAX's custom controls and Parameter Maps. The documentation can be found under 'Must Read Sections for All Developers / Custom User Interface Controls and Must Read Sections for All Developers /Parameter Maps'.

Gurjeet Sidhu is the lead programmer at Dhruva Interactive which is a company in India a rare and unique organism in Indiaas there aren't may game teams in India. Gurjeet has been doing graphics programming for about 4 years now and spent most of last year working on the port of Infogrammes title Mission Impossible from the N64 to the PC; unfortunately that got scrapped at a pretty advanced stage. Right now, the main purpose of his existence is to investigate culling schemes and decide what he'll use for the next version of our game engine. You can reach Gurjeet at [email protected]

Read more about:

Features

About the Author

Gurjeet Sidhu

Blogger

Gurjeet Sidhu is the lead programmer at Dhruva Interactive which is a company in India a rare and unique organism in Indiaas there aren't may game teams in India. Gurjeet has been doing graphics programming for about 4 years now and spent most of last year working on the port of Infogrammes title Mission Impossible from the N64 to the PC; unfortunately that got scrapped at a pretty advanced stage. Right now, the main purpose of his existence is to investigate culling schemes and decide what he'll use for the next version of our game engine. You can reach Gurjeet at [email protected].

Daily news, dev blogs, and stories from Game Developer straight to your inbox

You May Also Like