So we left off in the last part discussing how deferred lighting works. Lets have a look at the actual implementation (it was checked into GitHub a little while ago, I haven't labeled it yet though).
First off, I'll be changing the shaders to work for deferred lighting, that means they no longer work for our render to texture example that we used for our 3rd LOD of our trees. I could off course easily add a set of shaders to render that texture but I didn't feel that would add to our discussion, just distract from it. For now I've disabled that option but obviously the code for doing so is still in the previous examples and with a little bit of work you could change the engine to support both single stage and deferred rendering.
We've also switched off transparency support for now.
We don't change anything to rendering our shadow maps.
gBuffer
At the heart of our new rendering technique is what is often called rendering to a geometric buffer (because it holds various geometric data for our scene).
I've created a new library for this called gbuffer.h which is implemented in the same single file way we're used to right now.
I have to admit that at this stage I'm very tempted to rejig the engine to a normal header files + source files approach so I can compile the engine into a library to include in here. Anyway, I'm getting distracted :)
Note also at this point that I've added all the logic for the lighting stage into this file as well so you'll find structures and methods for those as well. We'll get there in due time.
An instance of a geographic buffer is contained within a structure called gBuffer which contains all the texture and the FBO we'll be using to render to the frame buffer.
In engine.c we define a global pointer to the gBuffer we'll be using and initialise this by calling newGBuffer in our engineLoad function and freeing our gBuffer in engineUnload.
Note that there is a HMD parameter send to the gBuffer routine which when set applies an experimental barrel distortion for head mounted devices such as a Rift or Vive. I won't be talking about that today as I haven't had a chance to hook it up to an actual HMD but I'll spend a post on it on its own once I've done so and worked out any kinks.
The gBuffer creates several textures that are all mapped as outputs on the FBO. These are hardcoded for now. They are only initialised in newGBuffer, they won't actually be created until you use the gBuffer for the first time and are recreated if the buffer needs to change size.
I have an enum called GBUFFER_TEXTURE_TYPE that is a nice helper to index our textures and then there are a number of arrays defined that configure the textures themselves:
// enumeration to record what types of buffers we need enum GBUFFER_TEXTURE_TYPE { GBUFFER_TEXTURE_TYPE_POSITION, /* Position */ GBUFFER_TEXTURE_TYPE_NORMAL, /* Normal */ GBUFFER_TEXTURE_TYPE_AMBIENT, /* Ambient */ GBUFFER_TEXTURE_TYPE_DIFFUSE, /* Color */ GBUFFER_TEXTURE_TYPE_SPEC, /* Specular */ GBUFFER_NUM_TEXTURES, /* Number of textures for our gbuffer */ }; ... // precision and color settings for these buffers GLint gBuffer_intFormats[GBUFFER_NUM_TEXTURES] = { GL_RGBA32F, GL_RGBA, GL_RGBA, GL_RGBA, GL_RGBA}; GLenum gBuffer_formats[GBUFFER_NUM_TEXTURES] = { GL_RGBA, GL_RGBA, GL_RGBA, GL_RGBA, GL_RGBA }; GLenum gBuffer_types[GBUFFER_NUM_TEXTURES] = { GL_FLOAT, GL_FLOAT, GL_UNSIGNED_BYTE, GL_UNSIGNED_BYTE, GL_UNSIGNED_BYTE }; GLenum gBuffer_drawBufs[GBUFFER_NUM_TEXTURES] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2, GL_COLOR_ATTACHMENT3, GL_COLOR_ATTACHMENT4 }; char gBuffer_uniforms[GBUFFER_NUM_TEXTURES][50] = { "worldPos", "normal", "ambient", "diffuse", "specular" };When you look at how different people have implemented deferred lighting you'll see they all have a slightly different mix of outputs.
At minimum you'll find an output for position, color and normal. Other outputs depend on what sort of capabilities you want to enable in your lighting. Obviously the more outputs you have, the more overhead you have in clearing those buffers and reading from them in the lighting stage.
There are two other outputs I've added.
One is an ambient color output. Now this one you probably won't see very often. As we saw in our original shaders we simply calculate the ambient color as a fraction of the diffuse color so why store it separately? Well I use it in this case to render our reflection map output to so I know the color is used in full but another use would be for self lighting properties of a material. It is definitely one you probably want to leave out unless you have a specific use for it.
The other is the specular color output. Note that this is the base color that we use for specular output, not the end result. I also 'abuse' the alpha channel to encode the shininess factor. In many engines you'll see that this texture is used only to store the shininess factor because often either the diffuse color is used or the color of the light. If you go down this route you can also use the other 3 channels to encode other properties you want to use in your lighting stage.
Obviously there is a lot of flexibility here in customising what extra information you need. But lets look at the 3 must haves.
Position, this texture stores the position in view space of what we're rendering. We'll need that during our lighting stage so we can calculate our light to object vector for our diffuse and specular lighting especially for things like spotlights. This is by far the largest output buffer using 32bit floats for each color channel. Note that as we're encoding our position in a color, and our color values run from 0.0 to 1.0, we need to scale all our positions to that range.
Diffuse color, this texture stores the color of the objects we're rendering. Basically this texture will look like our end result but without any lighting applied.
Normals, this texture stores the normals of the surfaces we're rendering. We'll need these to calculate the angle at which light hits our surface to determine our intensities.
Changing our shaders
At this stage we're going to seriously dumb down our shaders. First off I've created an include file for our fragment shaders called output.fs:
layout (location = 0) out vec4 WorldPosOut; layout (location = 1) out vec4 NormalOut; layout (location = 2) out vec4 AmbientOut; layout (location = 3) out vec4 DiffuseOut; layout (location = 4) out vec4 SpecularOut; uniform float posScale = 1000000.0;Looks pretty similar to our attribute inputs but not we use outputs. Note we've defined an output for each of our textures. The uniform float posScale is simply a configurable factor with which we'll scale our positions to bring them into the aforementioned 0.0 - 1.0 range.
Now I'm only going to look at one fragment shader and a dumbed down version of our standard shader at that but our outputs are used:
#version 330 in vec4 V; // position of fragment after modelView matrix was applied in vec3 Nv; // normal vector for our fragment (inc view matrix) in vec2 T; // coordinates for this fragment within our texture map #include "outputs.fs" void main() { vec4 fragcolor = texture(textureMap, T); WorldPosOut = vec4((V.xyz / posScale) + 0.5, 1.0); // our world pos adjusted by view scaled so it fits in 0.0 - 1.0 range NormalOut = vec4(Nv, 1.0); // our normal adjusted by view AmbientOut = vec4(fragcolor.rgb * ambient, 1.0); DiffuseOut = vec4(fragcolor.rgb * (1.0 - ambient), 1.0); SpecularOut = clamp(vec4(matSpecColor, shininess / 256.0), 0.0, 1.0); }So we can see that we just about copy all the outputs of our vertex shader right into our fragment shader. The only thing we're doing is scaling our position and our shineness factor.
We make similar changes to all our shaders. Note that for some shaders like our skybox shader we abuse our ambient output to ensure no lighting is applied.
Note that I have removed most of the uniform handling on our shaders that relate to lighting from our shader library. I could have left it in place and unused but for now I decided against that. You'll have to put them back in if you want to mix deferred and direct lighting.
Rendering to our gBuffer
Now that we've created our gBuffer and changed our shaders it is time to redirect our output to the gBuffer.
Our changes here are fairly simple. In our engineRender routine we simply add a call to gBufferRenderTo(...) to initialise our textures and make our FBO active. Our output now goes to our gBuffer.
We first clear our output but note though that I no longer clear the color buffer, only the depth buffer. Because of our skybox I know our scene will cover the entire output and thus there really is no need for this overhead.
The rest of the logic is pretty much the same as before as our shaders do most of the work but once our scene is rendered to the gBuffer we do have more work to do.
We're no longer outputting anything to screen, we now need to use our gBuffer to do our lighting pass to get our final output. Before we do so we first need to unset the FBO:
// set our output to screen glBindFramebuffer(GL_FRAMEBUFFER, 0); glViewport(wasviewport[0],wasviewport[1],wasviewport[2],wasviewport[3]); glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);
And then we call gBufferDoMainPass to perform our main lighting pass.
After this we should do our additional calls for other lights we want to apply to our scene but that's something we'll come back to.
Our main lighting shaders
Before we can look at our lighting pass we need some more shaders. At this point all we have implemented is our main pass which applies our global lighting over our whole scene. To apply this we're going to draw two triangles making up a rectangle that fills the entire screen. We need to render each pixel on the screen once and this logic is thus applied fully in our fragment shader.
Our vertex shader (geomainpass.vs) for this pass is thus pretty simple:
#version 330 out vec2 V; void main() { // our triangle primitive // 2--------1/5 // | /| // | / | // | / | // | / | // |/ | //0/3--------4 const vec2 coord[] = vec2[]( vec2(-1.0, 1.0), vec2( 1.0, -1.0), vec2(-1.0, -1.0), vec2(-1.0, 1.0), vec2( 1.0, 1.0), vec2( 1.0, -1.0) ); V = coord[gl_VertexID]; gl_Position = vec4(V, 0.0, 1.0); }We don't need any buffers for this and we thus render these two triangles with a simple call to glDrawArrays(GL_TRIANGLES, 0, 3 * 2).
The magic happens in our fragment shader (geomainpass.fs, I've left out the barrel distortion in the code below):
#version 330 uniform sampler2D worldPos; uniform sampler2D normal; uniform sampler2D ambient; uniform sampler2D diffuse; uniform sampler2D specular; uniform float posScale = 1000000.0; // info about our light uniform vec3 lightPos; // position of our light after view matrix was applied uniform vec3 lightCol = vec3(1.0, 1.0, 1.0); // color of the light of our sun #include "shadowmap.fs" in vec2 V; out vec4 fragcolor; void main() { // get our values... vec2 T = (V + 1.0) / 2.0; vec4 ambColor = texture(ambient, T); if (ambColor.a < 0.1) { // if no alpha is set, there is nothing here! fragcolor = vec4(0.0, 0.0, 0.0, 1.0); } else { vec4 V = vec4((texture(worldPos, T).xyz - 0.5) * posScale, 1.0); vec3 difColor = texture(diffuse, T).rgb; vec3 N = texture(normal, T).xyz; vec4 specColor = texture(specular, T); // we'll add shadows back in a minute float shadowFactor = shadow(V); // Get the normalized directional vector between our surface position and our light position vec3 L = normalize(lightPos - V.xyz); float NdotL = max(0.0, dot(N, L)); difColor = difColor * NdotL * lightCol * shadowFactor; float shininess = specColor.a * 256.0; if ((NdotL != 0.0) && (shininess != 0.0)) { // slightly different way to calculate our specular highlight vec3 halfVector = normalize(L - normalize(V.xyz)); float nxHalf = max(0.0, dot(N, halfVector)); float specPower = pow(nxHalf, shininess); specColor = vec4(lightCol * specColor.rgb * specPower * shadowFactor, 1.0); } else { specColor = vec4(0.0, 0.0, 0.0, 0.0); }; fragcolor = vec4(ambColor.rgb + difColor + specColor.rgb, 1.0); } }Most of this code you should recognise from our normal shader as we're basically applying our lighting calculations exactly as they were in our old shaders. The difference being that we're getting our intermediate result from our textures first.
Because these shaders are very different from our normal shaders and we simply initialise them when we build our gBuffer I've created a seperate structure for them called lightShader with related functions. They still use much of the base code from our shader library.
Note also that because we're rendering the entire screen and no longer using a z-buffer, I've uncommented the code that clears our buffers in our main loop.
And thats basically it. There is a bit more too it in nitty gritty work but I refer you to the source code on github.
Next up
I really need to add some images of our intermediate textures and I may add those to this writeup in the near future but I think this has covered enough for today.
I'll spend a short post next time about the barrel distortion if I get a chance to borrow a friends Rift or Vive.
The missing bit of this writeup is adding more lights. At this point in time we've not won anything over our earlier implementation, we've actually lost due to the added overhead.
Once we start adding lights to our scene the benefits of this approach will start to show though I've learned that on the new generation hardware a single pass renderer can render multiple lights more then fast enough to make the extra efforts and headaches not worth it.
No comments:
Post a Comment