February 1998
Get Fast and Simple 3D Rendering with DrawPrimitive and DirectX 5.0
Download drawPrimitive.exe (137KB)
Ron Fosner, who lives and breaths 3D graphics, runs Data Visualization, a 3D graphics consulting group specializing in creating fast OpenGL and Direct3D applications. You can reach
him at Ron@directx.com.
|
One of the cool things
that's happened in the last two years has been the widespread adoption of 3D graphics. Whether it's viewing business graphics, virtual reality, 3D Web sites, or just playing Quake, 3D is rapidly becoming a standard feature in many applications. This Christmas should see the largest number of 3D accelerated video cards everan estimated 42 million 3D graphics chips will be sold in 1997. If you've never considered 3D important simply because most 3D applications run slowly on your PC, then you might want to check out the cards that are coming out. Adding a 3D accelerator to your system can make those 3D applications run 3 to 10 times faster, just by adding a card that you can find in the $200 or less range. Look for video cards with 3D chipsets from ATI, 3Dfx, 3Dlabs, NVIDIA, and S3.
Of course, there's a catch to this speed. Typically 3D applications had specific ports to various hardwarethus there'd be a software-only version, an ATI version, a 3Dfx version, and so on. This made it a real nightmare for software developers who had to write all the Se different flavorsor, more typically, pick only two or three and let everyone else run the software-only version. This can turn a 3D graphics accelerator into nothing more than just dumb video memory. The latest 3D interface from Microsoft aims to change all that, with a brand new interface for 3D object creation called DrawPrimitive. I'm going to cover the new features that can be found in DirectX® 5.0 Direct3D®features that greatly improve the usability of Direct3D and make it easier than ever to create 3D graphics. Microsoft originally targeted the game development community with DirectX. Lately, it's been broadening its reach to include a more general multimedia audience. DirectX is Microsoft's attempt to obviate the need to write all the Se software flavors. Two features are key: a common API that software developers can write to that forces the hardware manufacturers to write the API-hardware driver code, and a rich hardware emulation layer that raises the least common denominator. For example, typically most 3D programs had their own transformation codecode that would compute how a 3D object would look from the current viewpoint. the Output from the transformation code is what would get written to the video buffer as pixels. Direct3D, the 3D graphics component of DirectX, has this layer built in so that you no longer have to be an expert in matrix math to do 3D graphics (although it still doesn't hurt!). If you're familiar with the Direct3D Immediate Mode version of the Direct3D API, then you're probably aware of the controversy surrounding it. There has been a huge on-going debate just about everywhere that 3D graphics is discussedInternet newsgroups, magazines, graphics conferences; even newspapers have gotten into the fray. Direct3D Immediate Mode has been repeatedly compared to another, more mature 3D API, OpenGL, and found to be lacking, mostly by proponents of OpenGL. By mature, I refer to the fact that OpenGL has a 10-year history on SGI hardware-accelerated machines. Microsoft initially took a rather standoffish attitude toward acknowledging the problems of Direct3D and Direct3D Immediate Mode in particular, much to the annoyance of the Direct3D users. Lately, however, starting with DirectX 5.0, Microsoft has taken steps to address the previously denied shortcomings of Direct3D. It is not too surprising that some of the Se changes make Direct3D Immediate Mode look much more like OpenGLa backhanded acknowledgment that perhaps the lessons of 3D hardware acceleration that SGI has learned from the last 10 years and placed in the OpenGL API are better than the software-only rendering engine that made up the Origins of Direct3D. It's interesting to note that both OpenGL and Direct3D are supported in Windows® 95 and Windows NT®, and that Windows 98 and Windows NT 5.0 will support the M both natively. In fact, the debate surrounding Direct3D and OpenGL has spurred innovation in both APIs and forced hardware vendors to start coming out with driver support for both. It's good news for programmers in the sense that no matter what flavor of API you like, they both are going to get better and better. In fact, at the 1997 Computer Game Developer's Conference, practically every video board vendor had support for Direct3D and OpenGL, a huge change from last year. the Se are the cards that are starting to show up now. But to take advantage of the features in the Se new cards, you'll need to know how to program for the M. This is where DirectX and Direct3D in particular come in. If you've attempted programming in Direct3D Immediate Mode, you're probably familiar with something called execute buffers. An execute buffer is essentially a memory location that you would load up with a series of either state changes (for example, lighting or viewing changes) or primitive construction instructions (vertex information). Programming execute buffers is not a task for novices: let's just say that the pains associated with learning how to use execute buffers are both potent and plentiful. Adding to the programming misery, Microsoft provided almost no documentation about how to construct the exe buffers aside from some sample programs, which used a collection of C macros to insert information into the Se buffers. And the Se macros were not really very useful for creating real programs. There was no information about the Optimal execute buffer size or how to place information in the buffers that wasn't provided by the samples (for example, how to use multiple textures in a single execute buffer). If you placed the wrong information in an execute buffer and then passed it to Direct3D to process, your program would most certainly crash. This alone drove many away from even considering Direct3D as a viable 3D API, since most folks find trial-and-error programming sprinkled with frequent reboots not that productive or enjoyable. Those who did manage to figure out Direct3D execute buffers usually ended up tossing out those heinous macros and wrapping the M in a C++ class. With DirectX 5.0, Microsoft has finally acknowledged that execute buffers were a "mistake" and rectified the situation with the introduction of better-organized samples, more robust code, and, of course, the DrawPrimitive methods. It's a source of amusement to the OpenGL proponents that DrawPrimitive looks a lot like OpenGL's primitive construction interface. In reality this isn't that surprising, since the goal of OpenGL is to provide a robust, narrow 3D API that makes it easy to implement as a driver to a hardware accelerator. This actually makes it nice for developers, since once you learn one API's method of constructing primitives, it's straightforward to port the code to the Other. The biggest advantage of DrawPrimitive is that it makes constructing a primitive much easier than using execute buffers. It's still a bit tedious, but remember, this is a low-level 3D API. You wouldn't be here if you weren't interested in eking out every last bit of speed and control over your application. Once you construct your primitive, you then pass it off to the API where, depending upon the hardware, it might be accelerated. Unless you're intimately familiar with Direct3D primitives, you should read the section on primitives. While the concept of primitives is a simple one, there are subtleties in their construction that are easy to miss.
Primitives
The next type of primitive is the most interesting, the triangle primitive. Figure 2 shows the three types of triangle primitives that DrawPrimitive supports. Again, the numbered vertexes indicate how DrawPrimitive expects the Se primitives to be constructed. Now I'm going to cover an important point about DrawPrimitive's primitives; this applies to all 3D APIs in general. If you examine the triangle list primitive and compare it to the Other two triangle primitives, you'll notice that one triangle list can be used to construct the Other two. What you should also notice is that both the triangle strip and triangle fan primitives require fewer triangles to be specified than if you used the triangle list. This is the key pointyou don't ever want to duplicate vertex __information if you don't have to. |
Figure 2 Triangle Primitives |
Execute Buffers versus DrawPrimitive
|
|
creates a new Direct3D object. It's just the same as you did previously in DirectX 3 code, but with a new interface specified by IID_IDirect3D2. The new DirectX 5.0 device model has the Direct3D device as a separate object from a DirectDraw surface. The IDirect3D2 interface gives access to this new functionality. IDirect3D2 is used to find or enumerate the types of devices supported. It identifies devices by unique CLSIDs. There are typically multiple Direct3D devices with different capabilities (some software, some hardware), but each supports the same set of interfaces. With DirectX 5.0, you now specify the CLSID to identify which type of device object you want. The CLSID (obtained from IDirect3D2::FindDevice or IDirect3D2::EnumDevices) is then used in a call to the IDirect3D2::CreateDevice method to create a device. the Se device objects support both the Original IDirect3DDevice and the new IDirect3DDevice2 interfaces. Unlike in DirectX 3, you cannot call QueryInterface on the Se objects to retrieve an IDirectDrawSurface interface. Instead, you must use the IDirect3DDevice2::GetRenderTarget method. You may notice that the relationship between Direct3D and DirectDraw has changed with DirectX 5.0, since the DirectDraw object now encapsulates both the DirectDraw and Direct3D states. When you create a DirectDraw object and then use the IDirectDraw2::QueryInterface method to obtain an IDirect3D2 interface, the reference count of the DirectDraw object is 2. This means that the lifetime of the Direct3D driver state is the same as that of the DirectDraw object. That is, releasing the Direct3D interface does not destroy the Direct3D driver statethat state is not destroyed until all references to that object (both DirectDraw and Direct3D references) have been released. Therefore, if you release a Direct3D interface while holding a reference to a DirectDraw driver interface, and then query the Direct3D interface again, the Direct3D state will be preserved. This is different from previous versions of DirectX, where a Direct3D device was aggregated off a DirectDraw surface. In the Se versions of DirectX, the IDirect3DDevice and IDirectDrawSurface were two interfaces to the same object. A given Direct3D object supported multiple 3D device types. The IDirect3D interface was used to find or enumerate the device types. The IDirect3D::EnumDevices and IDirect3D::FindDevice methods identified the various device types by unique interface IIDs, which were then used to retrieve a Direct3D device interface by a call to QueryInterface on a DirectDraw surface. The lifetimes of the DirectDraw surface and the Direct3D device were identical, since the same object implemented the M. The reason for this change is that the previous architecture did not allow the programmer to change the rendering target of the Direct3D device, which is now possible through the IDirect3DDevice2::SetRenderTarget method. The next step is to actually get a Direct3D device. As before, you execute a line of code with the Direct3D object to get the desired device. |
|
The parameter IID_IDirect3DxxxDevice is the identifier for the new device. This can be IID_IDirect3DHALDevice, IID_IDirect3DMMXDevice, IID_IDirect3DRampDevice, or IID_IDirect3DRGBDevice. The DirectDraw HEL (hardware emulation layer) supports the creation of texture, mipmap, and z-buffer surfaces. Because of the tighter integration of DirectDraw and Direct3D in DirectX 5.0, a DirectDraw-enabled system always provides Direct3D supportat the very least in software emulation. Therefore, the DirectDraw HEL exports the DDSCAPS_3DDEVICE flag to indicate that a surface can be used for 3D rendering. DirectDraw drivers for hardware-accelerated 3D display cards should use this flag to indicate the presence of hardware-accelerated 3D. Once you have your Direct3D device, you can set the state. You can use the new interfaces to set the render state, lighting state, viewport, transforms, and so on. |
|
So far, coding isn't much different from previous versions of DirectX, except that you don't need execute buffers to change the state. This alone is a huge improvement. The last step is to actually create and render the primitive. The new Direct3D interfaces allow you to construct a primitive work by specifying the primitive type and then the vertexes. No execute buffers, no pointers to keep track of. Very simple. To create a simple square using the simplest new DrawPrimitive interface, the code would look something like this: |
|
This is the simplest interface found in IDirect3DDevice2. Notice that I didn't even mention DrawPrimitive. The IDirect3DDevice2::Begin/Vertex/End paradigm is the simplest primitive construction method. the Only Direct3D method you can legally call between calls to IDirect3DDevice2::Begin and IDirect3DDevice2::End is IDirect3DDevice2::Vertex. This method makes it easy to construct primitives and to try out the new interface. Three more interfaces for primitive construction are found in the IDirect3DDevice2 interface, IDirect3DDevice2:: BeginIndexed, IDirect3DDevice2::DrawPrimitive, and IDirect3DDevice2::DrawIndexed-Primitive. Collectively, the Se four methods are referred to as the DrawPrimitive interfaces. If you are constructing a simple object, you might use the Begin/Vertex/End method. But for more complex objects, or objects where you already have an array of vertexes in the proper order (for example, a triangle strip), then you'd use the IDirect3DDevice2::DrawPrimitive interface. Using it's even simpler than the previous example, since it assumes that the vertex data is in order. |
|
This is a pretty simple way to construct an object, although it might look a little confusing at first. That code behaves the same as this code: |
|
It's preferable to use DrawPrimitive rather than the individual vertex method because you only need to pass in a pointer to the array of data, not every individual vertex. The next new primitive interface is perhaps the most powerful and, not too surprisingly, is also the most complicated. One of the problems that you might have noticed with both of the previous methods is that you must provide duplicate vertex information if the vertexes are not quite in the correct order for a collection of primitive types. Look at the wireframe cube shown in Figure 4. The cube has six faces, with each face containing four vertexes. Thus, using the Begin/Vertex/End method for a list of triangles, there would have to be (6 faces)(2 triangles/face)(3 vertexes/triangle) = 36 vertexes specified. Thirty-six seems like a lot just to specify a cube. Simplify by using two triangle fan primitives, picking one corner as the first fan's origin, and then using the three sides that join at that point as the leaves of the fan.
DrawIndexedPrimitive takes the same arguments as DrawPrimitive, but also takes a list of indexes that it uses to look up the vertexes, rather than assuming they're in the correct order. Figure 5 shows how the vertex list and the index list are used to process vertexes. The biggest advantage of DrawIndexedPrimitive is that you frequently get model information in this format. Most modeling programs save models by using a vertex list followed by a vertex index list. While it may seem to be an extra step to do the lookup, remember that each vertex in the primitive goes through multiple matrix transformations each frame. It's much simpler to look up an already transformed vertex than to take a duplicate vertex and run it though a few dozen floating-point calculations, particularly when you've got a model made up of thousands of vertexes with all of the M shared between two or more primitive shapes. |
Figure 5 DrawIndexedPrimitive Vertex Processing |
The last method is a combination of Begin and DrawIndexedPrimitive. The IDirect3DDevice2::BeginIndexed method defines the start of a primitive based on indexing into an array of vertexes, just as you did with DrawIndexedPrimitive. Instead of calling the Vertex interface as you would if you were using Begin, you use the Index method to specify the index into the vertex array. Below, I use an array of the indexes into the vertex array to specify the Order of vertexes, just as with DrawIndexedPrimitive. |
|
Well, that's essentially what DrawPrimitive is, and how you'd use it. Now, how would you actually use it in a program? Microsoft has done a great job in converting most of its Immediate Mode sample programs into DrawPrimitive, and since everyone who's interested in DrawPrimitive can get the SDK from Microsoft's Web site (http://www.
microsoft.com/directx), I've decided to use one of their sample programs in this article. You can get the SDK from the Web sites noted at the end of the article. Before I jump into that code, take a look at the parameters that you can use in the IDirect3DDevice2::Begin call in Figure 6. And you should examine the SDK documentation, for there are some really interesting capabilities hinted at there.
Boids
If you examine the vertex information, you'll notice that all of the vertexes differ in either location or in the vertex normal. In fact, you'll notice that all of the "duplicated" vertexes differ in their normal values. This is a very important point. When you say "vertex," you mean not only position, but any other type of information associated with that particular vertex. Positional data is the most obvious type of vertex information, but remember there's also texture coordinates, vertex normals, color information, edge flags, and so on, that may be associated with a particular vertex. So if any of the Se values are different, then you'll have to specify an entirely new vertex value to account for the Se differences.
Figure 11 shows two views of the shape of the rear of a boid (looking edge-on). On the left side, you can see that each surface has its own normal. Where the surfaces meet there would be a sharp change in the reflective angle of the surface when lighting calculations are performed. In other words, what you'd see would be three distinct surfaces making up the rear of a boid. On the right side you can see a representation of the actual values that are used with each vertex. Notice that the two vertexes in the center have normals associated with the M that are averaged values of the normals of the surfaces that share the particular vertex. |
Figure 11 Boid Normals |
Since lighting values are interpolated between vertexes of triangles, performing your own averaging of surface normals between adjacent triangles gives the effect of one smooth, curving surface when lighting calculations are performed between the Se vertexes. Since the colors of the triangles are the same, the Only visible difference is due to lighting effects. By performing this interpolation of normals between triangles, you can extend the inner-triangle vertex interpolation that Direct3D will perform for you and make the entire rear surface appear to be one smooth, curved surface. This effect is also used on the top and bottom surfaces to make the M appear smooth. This is an important and powerful trick that you can use to reduce the complexity of your models by making sharp-edged corners appear smooth and continuous.
Spheres
Other New DirectX 5.0 Features
|
From the February 1998 issue of Microsoft Systems Journal. |