Jump to content
  • Advertisement
Sign in to follow this  
  • entries
    2
  • comments
    4
  • views
    853

About this blog

Ramblings and thoughts on programming, OpenGL, and other things

Entries in this blog

 

OpenGL: A simple C++ memory manager for triangle mesh data (that allows for very few draw calls, even just 1) -- Part 2

Click here for part 1  Hello everyone. Today, we are going to continue working on our OpenGL memory manager. I'm going to show you how to use a couple of functions in the classes from part 1 that we haven't used yet, and why we will need to use these. I'll post a couple of diagrams to explain whats happening, which should hopefully make things clearer. I will also show you how to use the OpenGL command, glMultiDrawArrays, which allows you to send multiple draw calls (or just one) to the GPU at once, which can save some driver overhead. At the end, i'll show some sample code for how you would send the render data to OpenGL based on what is currently in the TerrainMemoryTracker. Before we start, just a quick note if you are actually using the source code I posted from part 1: I had to make a couple of minor updates to the TerrainMemoryTracker's header and cpp files. I've attached the updated versions in a new zip file at the end of this. Other than that, grab a beer and let's jump in. The Code in Practice Let's quickly recap part 1. We managed to get some sample code running, and displayed the output to the default console window, to understand what was happening step-by-step. We went through how insertNewCollection worked, and why the structure of the std::map was what it was.  A keen eye that's looked at the source code for TerrainMemoryTracker::insertNewCollection will notice that it has a return value of int, although we didn't actually use this in part 1 -- we just called the function. There is a reason for this, which we will get to, but before that, I'm going to introduce you to one of the other functions in TerrainMemoryTracker: checkForMemoryMovements. The name describes what it does: it checks to see if any OpenGL memory has to be copied into the swap buffer before we insert a new collection (element) into the map. In most cases, when you add a new element into the map, the difference in triangles to be rendered between the newly inserted element and what was there previously is different (unless the element to be added is at the very end of the map, in which case this doesn't matter, since you aren't replacing any element, just adding a new one); However, we need to account for when there is actually no need to shift memory (we would do nothing with the OpenGL buffers), as it is a good idea to handle every use-case you can (it's just a good practice in my opinion). Here is some code from my prototype engine, known as Organic: Don't worry about what OrganicGLManager::insertCollectionGLData is; it's just a function from my library. It takes data about a completed job as an argument (the same thing we did in part 1). Let me break this down real quick: -- OrganicGLManager is a non-static class that has a pointer to an instance of TerrainMemoryTracker (among other things). -- The terrainBufferID and terrainSwapID are just GLuint*. We will dereference the pointer within the function. -- OrganicGLWinUtils is a class that contains a bunch of static functions, each of which call one or more OpenGL API functions, that I use for abstracting away OpenGL things and making my code easier to read. I'm still in the process of working on this API library for OpenGL calls, so for times when I use basic one-off commands such as glBufferSubData above, I don't bother writing a function for (but that may change later on; it's just not critical right now). The only function we're using here simply takes the contents of the buffer in the first argument, and copies them into the second argument. The third argument is the byte offset position in the OpenGL buffer that you will begin reading from to do your copy. The fourth argument is the number of bytes you will copy. The fifth argument is the offset to begin the copy in the buffer you are writing to (aka our second argument). Here's the source code to explain: --RenderCollection is another class in my engine. It's just a class designed to hold data that we send to OpenGL, for terrain. Each element in TerrainMemoryMeta's terrainTracker member should have its own RenderCollection; this relationship is 1:1, with the bond being that they have the same unique key (see part 1 if you need to refresh on that). Each instance of a RenderCollection contains the byte size of the array that will be passed into OpenGL. The array itself is an instance of an std::unique_ptr of GLfloats, which we get for the call to glBufferSubData. --glBindBuffer, glBufferSubData...these should be self explanatory: we must bind to the buffer we will be sending our OpenGL data to.   Don't worry, i'm not going to leave you hanging. I just had to give you an outline of what these are before we get to the next part The writeBackOffset If you've ever written your own OpenGL program from scratch, and gotten to a point where you are drawing geometry from a data buffer, you've probably run into various issues where OpenGL renders things in very very bizarre ways. It could be that you didn't draw all the vertices, perhaps there is a typo or error in your custom shader code, or maybe there is a gap in your buffer when you perform the draw call. That last part is what will cause very strange geometry to appear on your screen: Oh yeah, i've been there -- it's not a pretty place! But learning the hard way is a good way to remember not to make that mistake again. Fortunately, I'll be saving you that pain. To understand why we need that writeBackOffset, we'll go back to our screenshot from part 1, where we output some code to the console window:  Back in part 1, the element that had a unique key (not the map key, remember) of 2,0,0 was replaced with a value of 6,0,0, because the 2,0,0 was flagged as replaceable. However, the byte size of the newly inserted element is different, by approximately 60 bytes. We need to do a couple of steps to actually make this work in practice. Remember that our map keeps track of the offsets in our OpenGL buffer. What we need to do is copy all the data in the OpenGL buffer (terrainBufferID), beginning at offset 180 and ending at 660. So 480 bytes. But once we insert the new element, with key (6,0,0), we will have to get the value of the new byte offset to write back to. This is achieved in the following way: 1. checkForMemoryMovements will find a replaceable element at 2,0,0 -- but will take note of the fact that the new element replacing it (6,0,0) has 60 bytes more data than what was in there previously. As a result, inserting this new element would containMovement. This value will be set to 1 in the returning TerrainMemoryMoveMeta. This return value will have the appropriate byte offsets that we will need to copy to the swap buffer. 2. Since there is movement required, we must copy to the swap buffer, using the returned values of the call to checkForMemoryMovements 3. We will now insert a new collection into the map, using a call to insertNewCollection, and use the writeBackOffset we get in the code to determine where in the buffer to write back to: When this is all done, we're good to go to insert our new OpenGL data for the element with a unique key of (6,0,0), that has 240 bytes. We don't have to worry about data overwriting, and our data is neatly packed. There's only one thing left we have to account for before moving on to the draw calls, and that is to clean up any unused replaceables. Removing unused replaceable elements If you recall from part 1, I mentioned that it's possible to have more replaceable elements flagged than you actually replace; in our code from part 1, we flagged 2 different elements as replaceable, but only replaced 1. It's probably a good idea to get rid of that unused replaceable element. So let's pretend in your game loop, you had 1 element you actually replaced, but 4 were flagged. Our call to insertCollectionGLData (or whatever else you want to call it in your own code) is called once for every collection we insert. But it doesn't do anything for removing unused replaceables. So, before I actually get ready to perform my draw call, I want to clean up those 3 elements I didn't bother using. In the same OrganicGLManager class, i've got another function that does just that: removeUnusedReplaceables(). Feel free to copy and paste this into your own function: This function performs much like what we wrote in the previous section, although instead of adding new collections, it just flat out remove all of the unused ones. It still does the same thing with copying to the swap buffer and back.   Drawn Together Hopefully it's been clear up to this point, because now we'll get into the nifty draw call stuff. I've got a function in the same OrganicGLManager class, called drawTerrainIndirect(). The meat and potatoes of this function is this: Assuming that you've called removeUnusedReplaceables() before doing the above, the terrainMemoryTracker should have 0 unused replaceables. That means we can use just 1 draw call. But you could also have multiple ones. Fortunately, either case is relatively simple to use in a call to glMultiDrawArrays, which allows you to send one or more draw calls to the GPU. If we assume your currently bound buffer is terrainBufferID, the call would look like this: And that concludes this tutorial. I'm currently fooling around with working on a texture tile atlas (it's very challenging to avoid bleeding tiles!!!), but the next topic I will probably touch on is a practical multithreaded job system. I hope this helped some of you; please feel free to leave feedback or questions. Thanks again! -Sweatpants Updated_Source.zip
 

OpenGL: A simple C++ memory manager for triangle mesh data (that allows for very few draw calls, even just 1) -- Part 1

Hello everyone, Today, i'm going to show you how to write a simple memory manager for managing OpenGL mesh data. The OpenGL requirements for this are relatively simple, and you'll need the following: -The byte size of a vertex in the VAO (so if you have 8 floats -- 3 for position, 2 for UV texture coords, 3 for normals, it's 32) -Two buffers of equal size: one for your entire mesh, and one to swap data to/from (this will be for part 2)   Assuming that your VAO is contained within one OpenGL buffer, this should work fine. This class keeps track of the beginning byte offsets, size in bytes, and ending bytes offsets of meshes in OpenGL memory, and compacts them into one contiguous block of OpenGL memory. This class also will also allow you to render all the meshes within it with a single draw call, which I will cover in part 2.  Any meshes flagged as unused (as in, you no longer need them to be rendered) are replaced (if there is something to replace them); if there is nothing to replace them, the class has functions that will give you values to help re-arrange the data in OpenGL, in order to keep it contiguous (that's why we need two buffers). This class doesn't utilize any OpenGL functions, but rather gives you the appropriate values you will need to pass to the OpenGL functions. Don't worry, I will show how to use the values returned by the class's functions in OpenGL, in part 2. I will also go over the few functions the class contains, to show how they are used. For now, we'll review how the class works -- this is my very first blog, and I don't want to overload anyone's brain! I will really appreciate your feedback!   Overview First, there's just a few things to go over here. The memory tracking class, TerrainMemoryTracker, uses std::map to keep track of the draw able meshes, in case you don't want to draw the entire mesh as one. The data for each mesh is stored in a class called TerrainMemoryTracker. Here's what the map looks like (from the attached TerrainMemoryTracker.h) std::map<int, TerrainMemoryMeta> terrainTracker; // an ordered map of terrain memory meta information TerrainMemoryTracker is a class that operates around an std::map. The term "collection" simply refers to a drawable mesh; I just use collection because of terminology from my engine. Each collection in the map is tracked by an instance of the class TerrainMemoryMeta. This class contains the beginning byte offset, the total byte size, the ending byte offset for the OpenGL buffer that the data resides in. It also has the collection's unique identifier, and the replaceable state (more on that later) for each collection. For each instance of these in the map, you need to define a variable that will be used to uniquely identify that entry. You might be thinking, why not just use terrainTracker's key (the int) for that? Well, the key of each map entry is used to keep the OpenGL memory contiguous, to keep things ordered; if you don't understand this now, it'll be made clear later on. So within my TerrainMemoryMeta class, I have my own unique identifier field, called EnclaveKeyDef::EnclaveKey. You can replace this with anything you want to -- an int, a double, whatever -- as long as there is no other element in the map that has this same value. Here is an example of what this looks like (see TerrainMemoryMeta.h): #pragma once #ifndef TERRAINMEMORYMETA_H #define TERRAINMEMORYMETA_H #include "EnclaveKeyDef.h" class TerrainMemoryMeta { public: int replaceable = 1; // indicates if the collection may be replaced. default will be "1", indicating not replaceable. EnclaveKeyDef::EnclaveKey collectionKey; // the key of the collection being tracked int byteOffsetBegin = 0; // the exact location in GL memory where this collection's data begins, measured as a byte offset int byteSize = 0; // the size in bytes of the render collection to render int byteOffsetEnd = 0; // the location where the byte offset ends }; #endif So you'd just change collectionKey above to be whatever you wanted. You'll have to go into the source code I post and change this a bit, but it shouldn't be hard to do -- the majority of TerrainMemoryTracker's functions iterate through the map until they find the value of collectionKey, and then select that element.  How it works Assuming you've copied the .cpp and .h files into your project, and if you're using Visual Studio, just follow along. All the functions in bold will be from the TerrainMemoryTracker class. The first thing you need to do, before doing anything else, is to call setTriangleSize. This takes in the size of a single vertex within the VAO as an argument. For example, if your vertex consists of 8 floats -- 3 for position, 2 for UV, 3 for normals -- the parameter you'd pass would be 8*sizeof(float), or 32. The function stores the triangle size value as 96. In my code below, i've got 3 floats for the position, and 2 for the UV. So i'll use the size of 20, meaning our triangle size is 60 bytes of OpenGL memory. If you recall how a typical std::map works, it automatically orders the elements. If you insert the keys 5 4 2 1 3, it's going to order them as 1 2 3 4 5. TerrainMemoryTracker uses it's maxIndex value to keep track of what this key will be. Every time a new key-value pair is inserted, the class increments maxIndex by one. This makes it relatively easy to determine how many draw calls are in the map, by calling size() on the container. It also makes it easy to compact the data -- the byteOffsetEnd of the first key-value pair (key 0) in the map, will become the byteOffsetBegin of the key-value pair with key 1 in the map. So every new element added should have it's byteOffsetBegin be equivalent to the byteOffsetEnd of the previous element.   Now let's flex our brains a little bit. Let's pretend 2 and 3 are meshes I no longer wish to render, making that memory in OpenGL essentially unused. That means these are "replaceable" (by setting replaceable to 0). We mark them as replaceable, by calling flagAsReplaceable. Next, we want to insert a new mesh, through a call to insertNewCollection. You'll recall previously that I said each key-value pair needs to have a unique identifier in the value (TerrainMemoryMeta). This is how insertNewCollection searches for items in the map that match it's input parameter, when it needs to check if the unique identifier exists in the map. It will iterate over all the elements in the map when attempting to do this. The function doesn't know whether or not the mesh you're inserting uses a unique identifier that already exists, so it will search the map for it. One of three things will happen: --1.) If the mesh already exists in the map (assuming valid collectionKey), that key-value pair will be the one updated; --2.) If the mesh doesn't exist in the map, but there is at least one replaceable key-value pair, the first replaceable one it finds will contain the new data. --3.) If conditions 1 and 2 aren't met, we'll just add a new element to the end of the map. If you're wondering what we do with the return value of this function, that will be covered in part 2. We're trying to keep things simple before we get to OpenGL, so for now, let us assume we will be inserting a new mesh after flagging 2 and 3 as replaceable:     Here is what's happening in the above code. I first insert 5 collections, which each have a unique key. jobResults1's unique key is 1, 0, 0...jobResults2's key is 2,0,0, and so on. This keeps them unique when in the map. Now, i'm going to flag jobResults2 and jobResults3 as being replaceable. The next time insertNewCollection is called, it will find that jobResults2 is the first replaceable it detects, and then overwrite it with the values of jobResults6. Note that we aren't actually inserting a new key-value pair into the map, we're just updating the value of an already existing key-value pair. But what about #3? That's wasted memory, that didn't get used! Well, after we are all done with our inserts on the replaceables, we will call removeUnusedReplaceablesAndShift. This function looks for the first replaceable it finds, and shifts the byte offsets in the map accordingly, so that all of the meshes offset value's (byteOffsetBegin and byteOffsetEnd) are contiguous. Basically, you call this when you are all done with your inserts -- it will remove any unused replaceables and compress (shift) the offsets. The number of times you'd call this is equal to the number of unused replaceables in the map. But for our case, we only had one unused replaceable, so we'll just call it once for simplicity's sake. Here is the output of the above code: You'll notice that after replacing element with key value 1 with updated data, that the byte size has increased by 60. That size of 60 must be reflected in the rest of the map, to account for where the meshes will begin and end in memory. We do this by adding that 60 to the byteOffsetBegin and byteOffsetEnd values in the elements that follow the one we replaced. So in our case, the element's with map keys of 2, 3 and 4 have had their offsets shifted by 60.  But the end result of this is, all triangles you would use are consolidated into one chunk of OpenGL memory -- allowing you to do one draw call. For our example, we could render all 10 triangles (600 bytes), because the memory is contiguous. But you could also render each one of them separately as well; if you didn't want to render the triangles for Key (4, 0, 0), you would have two draw calls: one draw call for the elements at index 0 and 1 (since those two are contiguous), and then a separate draw call for element 3. I hope this was informative for everyone reading. I'm going to attach the source files in a zip file at the end of this entry. Stay tuned for more later -- I will try my best to answer your questions (but i'm going on vacation til New Years Eve!) Sincerely, -Sweatpants               Source.zip

SweatpantsProgramming

SweatpantsProgramming

Sign in to follow this  
  • Advertisement
×

Important Information

By using GameDev.net, you agree to our community Guidelines, Terms of Use, and Privacy Policy.

GameDev.net is your game development community. Create an account for your GameDev Portfolio and participate in the largest developer community in the games industry.

Sign me up!