Monday, July 17, 2017

Graphics, Graphics, GRAPHICS!!!

Another Week and a Whole Lot of Graphics

I'll keep this one short otherwise I'll go on about graphics forever.  Long story short, I implemented a bunch of graphics features to make prototyping easy.  Tested the hell out of them too.  Also, most importantly, I learned a thing or two along the way.

Graphics Features

A summary of the things implemented: 
  • Shader and program management
  • Vertex and index buffering
  • Vertex array object
  • Textures
  • Framebuffer objects
  • Started procedural geometry

The Prelude: Immediate Mode

For people new to OpenGL, "immediate mode" rendering is when vertex attributes, such as color, texture coordinates, normals, and the position of a vertex itself, are sent to the GPU one at a time as needed, wait in the pipeline until a primitive forms, become part of the primitive drawn, and get discarded immediately.  Hence, immediate mode refers to how data is used and forgotten about immediately.  This was what I used to test my window's drawing abilities.  Despite it being terrible, it's great for short-term debugging.

With a simple triangle on the screen (not the one you see above, I'll get to that), I decided to write shader and shader program wrappers to test the programmable pipeline, the staple of any modern rendering framework.  With immediate mode attributes flowing through the pipeline, it was very easy to see what effect my shaders would have on them... and that the wrappers were working.  That being said, immediate mode needed to go...

Vertex Drawing

Enter "retained mode": instead of data being immediately used and discarded, modern geometry is typically stored in what's called a "vertex buffer object" (VBO) that lives in a persistent state on the GPU (the rendering context).  VBOs contain vertex data for a primitive as a collection of attributes.  I implemented a wrapper for this as well.

You're probably wondering, "But Dan, what if you have a lot of repeated vertices, doesn't that take up a lot of redundant space?"  I thought of that too.  For this we have another kind of buffer called either an "index buffer object" (IBO) or "element buffer object" (EBO).  This stores only a list of indices that describes the order in which OpenGL should select vertices from the VBO to send down the pipeline.  It's very useful for geometry with many repeating vertices.  Let's say we have a vertex with 8 floats, or 32 bytes, that is repeated 6 times; a non-indexed vertex buffer would need 6 copies of that vertex, so that's 192 bytes.  Alternatively, the single vertex could be stored in a vertex buffer, with the integer index of said vertex occurring in an EBO 6 times, which is only 24 bytes.  Yes, I implemented a wrapper for this as well.

Now you're probably wondering, "Dan, how does OpenGL know where the attributes are in the buffer if you're not explicitly telling it like immediate mode does?"  Well, for this there is a handy thing called a "vertex array object" (VAO), whose job it is to describe everything about data in a vertex buffer.  The offset in the buffer, the size of each attribute, how many elements, everything you'd need to know when drawing a primitive!  A VAO saves the state of a vertex buffer that it describes (and an index buffer if one is used), so when you want to draw something, you just have to turn on the VAO and say "draw" with how many vertices are being drawn.

All of these are part of what I call a "vertex drawable", which is basically a little structure that knows which VBO, EBO and VAO it uses for drawing.  But the buck doesn't stop there, oh no.  Your next question might be, "Dan, do you have a unique VBO, EBO and VAO for every object in the scene?"  Absolutely not!  The great thing about all these is that you can share buffers for data belonging to different primitives.  For this reason I created a "buffer" interface that helps keep track of the data stored; when a chunk of data is sent to a buffer with a specified size, the interface spits out the offset to that data, so you know where it begins in the buffer.  For a shared vertex buffer, you can use a new offset to new attribute data in the buffer to describe a new vertex type in a new VAO that points to the same buffer.  In other words, as long as you know where data for a vertex primitive begins in a buffer, you can stuff many different primitives' data in that buffer.  You can also use the same buffer for index data, you just need to know the offset to that as well.

Textures & Framebuffer Objects

This was more for completion if anything, I didn't really need these wrappers.  I just decided to write a wrapper for texture creation, either from raw user data or from a file (using DevIL).  It wasn't a bad idea because I realized I might actually want textured objects.

I also made a wrapper for framebuffer objects (FBO) so that it is possible to do offscreen rendering.  This may be useful for people writing debugging tools that should be overlaid on the main scene image.  Multiple render targets enabled, all the fun stuff.  But to make things interesting, I also implemented a "double FBO" which is basically an offscreen double buffer.  It has a swap function so that the back buffer's targets are used for drawing while the front buffer's targets can be sampled.  This could be incredibly useful for an algorithm with many passes, such as bloom, because instead of creating and having to manage two separate FBOs for alternating passes, just create one double FBO and have it manage the data flow.

Reference-Counting Graphics Handles

Another thing I cooked up to help manage all of the above madness is a reference-counting handle object.  Anything with an OpenGL handle has one of these, and whenever something references an object with an OpenGL handle, a counter is incremented.  When one of these resources should be freed, you call "release" and the counter decrements.  When the counter hits zero, the appropriate release function is called.

Now I've always heard people say "be careful with function pointers" but I never really had any problems with them... until now.  The aforementioned "appropriate release function" is just a pointer to a facade function that simply calls the appropriate OpenGL release function, or functions if the object has multiple OpenGL handles associated with it.  What I didn't realize, however, is that hotloading the demo results in these functions' addresses going "out of scope", resulting in dangling pointers.  I was confused the first time I experienced this: I was telling a graphics object to release, and it should have just destroyed the object, but instead the program would just jump to a random line of code.  This was especially confusing with breakpoints set because you'd be on one one line and suddenly in a different file.  When I realized the problem, it made perfect sense: the command in assembly would be exactly the same as it was before hotloading, "jump to 0xWhatever", so without actually changing the value of 0xWhatever you could jump anywhere.

Two possible fixes: either destroy and reload all graphics objects, which I did not want to do (as it would defeat the purpose of hotloading, why not just close and reload at that point); or reassign these function pointers.  I chose the latter, and wrote an "update release callback" function for anything that has a graphics handle.  All it does is change the value of the release function pointer.  Problem solved, but at the same time I now see the real reason why function pointers can be a pain in the ass: the function moves.  Tricky functions, you.

A Blast from the Past

Alas, the explanation of the incredibly beautiful triangle you see above.  For me personally, seeing the triangle on-screen was very thought provoking: it heavily resembles the very first image I ever saw of shaders in action.  That was back in second year of undergrad in an intro graphics course.  Shaders were mentioned and described in minimal detail for no more than 10 minutes, with a screenshot to accompany, much like the one above.  And that was the last formal curriculum I had on shaders; all of the stuff I've done has been self-taught.  There was one lesson two years later when the TA of a totally non-graphics course took over lecture while the prof was away to teach our cohort about shaders, but by then it was clear I was the only one in the room (aside from the TA) who knew a single thing about this stuff.  Ah yes, I remember this moment clearly, and I'll happily boast about it.  At the same time everyone else was learning how to multiply matrices in a vertex shader, I was watching a 3D animated character I made (the 4-armed moldy orange... you'll see him later) dance around the screen with dual quaternion skinning and tangents displayed.  I was sitting at the front of the class, off to one side.  I doubt the guy next to me was listening to the TA.

And yet, at the top of this post, all you see is that damn triangle.  If I had been told that day about the work that goes into producing a triangle, *properly* mind you, I might have noped right the hell away from graphics forever.  But I endured, and the triangle you see uses a shared vertex/element buffer with a VAO to describe the vertices and a shader program to display any of the attributes in the primitive.  It is proper evidence that every single piece of my framework is alive and well, and more importantly, alive and well simultaneously and harmoniously.

What people don't realize about graphics, and animation for that matter (since both are very heavily algorithm-oriented) is that every step must be carefully traced, and to get a measly triangle on the screen requires a ton of prep followed by a single draw call.  And that's exactly what this is.  There is a story that I heard long ago (can't remember where) about a company whose first bout with programming for the PS3 was to spend far too many days getting a "black triangle" on the screen.  As soon as it appeared, they all threw their hands up, abandoned their post and went out drinking.  I greatly appreciate this, however this is not my first triangle, and to me it was a simple reminder of "Damn, I've come this far, might as well keep going."

Until next time...

I apologize for the lack of imagery in this post, the most interesting thing I have to test all of this is the triangle.  Soon I'll have demos that actually have things going on in them, so there will be stuff to show.

Next up: finishing procedural geometry and an OBJ loader!

No comments:

Post a Comment