Tuesday 19 April 2016

A simple shader preprocessor (part 29)

As I started planning out the additions for my deferred lighting renderer I realised I could no longer postpone implementing at least a basic shader preprocessor.

While some parts of the code can be moved more central other parts need to be further duplicated and in doing so the need to fix the same issues in multiple places make things harder and harder to maintain.

For my goals however I don't need the preprocessor to do much so we can keep everything very simple and we'll limit the functionality to the following:

  • support for a #include to insert the text from a file into our shader
  • supplying a number of "defines" which we can trigger logic
  • very basic #ifdef, #ifndef and #else logic that use these defines to include or exclude parts of the shader code

Changes to our system library


I was thinking about putting most of this code in our system.h file but decided against that for now. I may yet change this in the future. For now one support function has been added here:
// gets the portion of the line up to the specified delimiter(s)
// return NULL on failure or if there is no text
// returns string on success, calling function is responsible for freeing the text
char * delimitText(const char *pText, const char *pDelimiters) {
  int    len = 0;
  char * result = NULL;
  bool   found = false;
  int    delimiterCount;

  delimiterCount = strlen(pDelimiters) + 1; // always include our trailing 0 as a delimiter ;)

  while (!found) {
    int pos = 0;
    while ((!found) && (pos < delimiterCount)) {
      if (pText[len] == pDelimiters[pos]) {
        found = true;
      };
      pos++;
    };

    if (!found) {
      len++;
    };
  };

  if (len != 0) {
    result = malloc(len + 1);
    if (result != NULL) {
      memcpy(result, pText, len);
      result[len] = 0;
    };
  };

  return result;
};
This function splits off the first part of the text pointed to by pText from the start until it detects one of the delimiters or the end of the string.
This is fairly similar to the code we wrote before to read out material and object files line by line but without using our varchar implementation.

Changes to our varchar library


We are going to use varchar.h but in combination with a linked list to store our defines in. For this I've added 3 new functions:
// list container for varchars
// llist * strings = newVarcharList()
llist * newVarcharList() {
  llist * varcharList = newLlist((dataRetainFunc) varcharRetain, (dataFreeFunc) varcharRelease);
  return varcharList;
};
This first function simply returns a linked list setup to accept varchar objects.
// list container for varchars created by processing a string
// empty strings will not be added but duplicate strings will be
// llist * strings = newVarcharList()
llist * newVCListFromString(const char * pText, const char * pDelimiters) {
  llist * varcharList = newVarcharList();

  if (varcharList != NULL) {
    int    pos = 0;

    while (pText[pos] != 0) {
      // find our next line
      char * line = delimitText(pText + pos, pDelimiters);
      if (line != NULL) {
        int len = strlen(line);

        varchar * addChar = newVarchar();
        if (addChar != NULL) {
          varcharAppend(addChar, line, len);

          llistAddTo(varcharList, addChar);
        };

        if (pText[pos + len] != 0) {
          // skip our newline character
          pos += len + 1;
        } else {
          // we found our ending
          pos += len;
        };

        free(line);
      } else {
        // skip any empty line...
        pos++;
      };
    };
  };

  return varcharList;
};
This method uses our new delimitText function to pull a given string appart and add each word in the string as an entry into a new linked list.
// check if our list contains a string
bool vclistContains(llist * pVCList, const char * pText) {
  if ((pVCList != NULL) && (pText != NULL)) {
    llistNode * node = pVCList->first;

    while (node != NULL) {
      varchar * text = (varchar *) node->data;

      if (varcharCmp(text, pText) == 0) {
        return true;
      };

      node = node->next;
    };
  };

  // not found
  return false;
};
And finally a function that checks if a given word is present in our linked list.

Changes to our shader library


The real implementation can be found in our shader library. We've added a new parameter to our newShader function so we can pass it the defines we want to use for that shader:
shaderInfo * newShader(const char *pName, const char * pVertexShader, const char * pTessControlShader, const char * pTessEvalShader, const char * pGeoShader, const char * pFragmentShader, const char *pDefines) {
  shaderInfo * newshader = (shaderInfo *)malloc(sizeof(shaderInfo));
  if (newshader != NULL) {
    llist * defines;
    ...
    // convert our defines
    defines = newVCListFromString(pDefines, " \r\n");

    // attempt to load our shader by name
    if (pVertexShader != NULL) {
      shaders[count] = shaderLoad(GL_VERTEX_SHADER, pVertexShader, defines);
      if (shaders[count] != NO_SHADER) count++;      
    };
    ...
    // no longer need our defines
    if (defines != NULL) {
      llistFree(defines);
    };
    ...
  return newshader;
};
We first convert our new parameter pDefines into a linked list of varchars by calling our new newVCListFromString function.
We then pass our new linked list to each shaderLoad call so it can be used by our preprocessor.
Finally we deallocate our linked list and all the varchars held within.

The only change in shaderLoad is that it no longer called loadFile directly but instead calls shaderLoadAndPreprocess:
varchar * shaderLoadAndPreprocess(const char *pName, llist * pDefines) {
  varchar * shaderText = NULL;

  // create a new varchar object for our shader text
  shaderText = newVarchar();
  if (shaderText != NULL) {
    // load the contents of our file
    char * fileText = loadFile(shaderPath, pName);

    if (fileText != NULL) {
      // now loop through our text line by line (we do this with a copy of our pointer)
      int    pos = 0;
      bool   addLines = true;
      int    ifMode = 0; // 0 is not in if, 1 = true condition not found, 2 = true condition found

      while (fileText[pos] != 0) {
        // find our next line
        char * line = delimitText(fileText + pos, "\n\r");

        // found a non-empty line?
        if (line != NULL) {
          int len = strlen(line);

          // check for any of our preprocessor checks
          if (memcmp(line, "#include \"", 10) == 0) {
            if (addLines) {
              // include this file
              char * includeName = delimitText(line + 10, "\"");
              if (includeName != NULL) {
                varchar * includeText = shaderLoadAndPreprocess(includeName, pDefines);
                if (includeText != NULL) {
                  // and append it....
                  varcharAppend(shaderText, includeText->text, includeText->len);
                  varcharRelease(includeText);
                };
                free(includeName);
              };
            };
          } else if (memcmp(line, "#ifdef ", 7) == 0) {
            if (ifMode == 0) {
              char * ifdefined;

              ifMode = 1; // assume not defined....
              ifdefined = delimitText(line + 7, " ");
              if (ifdefined != NULL) {
                // check if our define is in our list of defines
                if (vclistContains(pDefines, ifdefined)) {
                  ifMode = 2;
                };
                free(ifdefined);
              };
              addLines = (ifMode == 2);              
            } else {
              errorlog(SHADER_ERR_NESTED, "Can't nest defines in shaders");
            };
          } else if (memcmp(line, "#ifndef ", 8) == 0) {
            if (ifMode == 0) {
              char * ifnotdefined;

              ifMode = 1; // assume not defined....
              ifnotdefined = delimitText(line + 7, " ");
              if (ifnotdefined != NULL) {
                // check if our define is not in our list of defines
                if (vclistContains(pDefines, ifnotdefined) == false) {
                  ifMode = 2;
                };
                free(ifnotdefined);
              };
              addLines = (ifMode == 2);              
            } else {
              errorlog(SHADER_ERR_NESTED, "Can't nest defines in shaders");
            };
          } else if (memcmp(line, "#else", 5) == 0) {
            if (ifMode == 1) {
              ifMode = 2;
              addLines = true;
            } else {
              addLines = false;
            };
          } else if (memcmp(line, "#endif", 6) == 0) {
            addLines = true;
            ifMode = 0;
          } else if (addLines) {
            // add our line
            varcharAppend(shaderText, line, len);
            // add our line delimiter
            varcharAppend(shaderText, "\r\n", 1);
          };

          if (fileText[pos + len] != 0) {
            // skip our newline character
            pos += len + 1;
          } else {
            // we found our ending
            pos += len;
          };

          // don't forget to free our line!!!
          free (line);
        } else {
          // skip empty lines...
          pos++;
        };
      };

      // free the text we've loaded, what we need has now been copied into shaderText
      free(fileText);
    };

    if (shaderText->text == NULL) {
      varcharRelease(shaderText);
      shaderText = NULL;
    };
  };

  return shaderText;
};
I'm not going to detail each and every section, I hope the comments do a good enough job for that. In a nutshell however, we start by creating a new varchar variable called shaderText which is what we'll end up returning. This means that our shaderLoad function also has a small change to work with a varchar instead of a char pointer as a result.
After this we load the contents of our shader file into a variable called fileText but instead of using this directly we use delimitText to loop through our shader text one line at a time.
For each line we check if it starts with one of our preprocessor commands and if so handle the special logic associated with it. If not we simply add our line to our shaderText variable.

#include is the first preprocessor command we handle, it simply checks the filename presented and attempts to load that file by calling shaderLoadAndPreprocess recursively.

This is followed by the code that interprets our #ifdef, #ifndef, #else and #endif preprocessor commands. These basically check if the given define is present in our linked list. They toggle the values of ifMode and addLines that control whether we ignore text in our shader file or add the lines to our shaderText.

Changes to our shaders


I've made two changes to our shaders, the first is that I've created a new shader files called "shadowmap.fs" that contains our samplePCF, shadow and shadowTest functions and we use #include in the various fragment shaders where we need these functions.

The second change is that I've combined our flatshader.fs, textured.fs and reflect.fs fragment shaders into a single standard.fs file that looks as follows:
#version 330

// info about our light
uniform vec3      lightPos;                         // position of our light after view matrix was applied
uniform float     ambient = 0.3;      // ambient factor
uniform vec3      lightcol = vec3(1.0, 1.0, 1.0);   // color of the light of our sun

// info about our material
uniform float     alpha = 1.0;                      // alpha for our material
#ifdef textured
uniform sampler2D textureMap;                       // our texture map
#else
uniform vec3      matColor = vec3(0.8, 0.8, 0.8);   // color of our material
#endif
uniform vec3      matSpecColor = vec3(1.0, 1.0, 1.0); // specular color of our material
uniform float     shininess = 100.0;                // shininess

#ifdef reflect
uniform sampler2D reflectMap;                       // our reflection map
#endif

// these are in world coordinates
in vec3           E;                                // normalized vector pointing from eye to V
in vec3           N;                                // normal vector for our fragment

// these in view
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
in vec4           Vs[3];                            // our shadow map coordinates
out vec4          fragcolor;                        // our output color

#include "shadowmap.fs"

void main() {
#ifdef textured
  // start by getting our color from our texture
  fragcolor = texture(textureMap, T);  
  fragcolor.a = fragcolor.a * alpha;
  if (fragcolor.a < 0.2) {
    discard;
  };
#else
  // Just set our color
  fragcolor = vec4(matColor, alpha);
#endif

  // Get the normalized directional vector between our surface position and our light position
  vec3 L = normalize(lightPos - V.xyz);
  
  // We calculate our ambient color
  vec3  ambientColor = fragcolor.rgb * lightcol * ambient;

  // Check our shadow map
  float shadowFactor = shadow(Vs[0], Vs[1], Vs[2]);
  
  // We calculate our diffuse color, we calculate our dot product between our normal and light
  // direction, note that both were adjusted by our view matrix so they should nicely line up
  float NdotL = max(0.0, dot(Nv, L));
  
  // and calculate our color after lighting is applied
  vec3 diffuseColor = fragcolor.rgb * lightcol * (1.0 - ambient) * NdotL * shadowFactor;

  // now for our specular lighting
 vec3 specColor = vec3(0.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(Nv, halfVector));
    float specPower = pow(nxHalf, shininess);
  
    specColor = lightcol * matSpecColor * specPower * shadowFactor;
  };

#ifdef reflect
  // add in our reflection, this is one of the few places where world coordinates are paramount. 
  vec3  r = reflect(E, N);
  vec2  rc = vec2((r.x + 1.0) / 4.0, (r.y + 1.0) / 2.0);
  if (r.z < 0.0) {
   r.x = 1.0 - r.x;
  };
  vec3  reflColor = texture(reflectMap, rc).rgb;

  // and add them all together
  fragcolor = vec4(clamp(ambientColor+diffuseColor+specColor+reflColor, 0.0, 1.0), fragcolor.a);
#else
  // and add them all together
  fragcolor = vec4(clamp(ambientColor+diffuseColor+specColor, 0.0, 1.0), fragcolor.a);
#endif
}
Note the inclusion of our #ifdef blocks to change between our various bits of logic while reusing code that is the same in all three shaders.
We can now change our shader loading code in engine.h to the following:
  colorShader = newShader("flatcolor", "standard.vs", NULL, NULL, NULL, "standard.fs", "");
  texturedShader = newShader("textured", "standard.vs", NULL, NULL, NULL, "standard.fs", "textured");
  reflectShader = newShader("reflect", "standard.vs", NULL, NULL, NULL, "standard.fs", "reflect");
If we need it we could very quickly add a fourth shader that combines texture mapping and reflection mapping by simply passing "textured reflect" as our defines.

In the same way I've combined our shadow shaders into a single shader file.

Obviously there is a lot of room for improvement here, but it is a start and enough to keep us going for a little bit longer.

Download the source here

What's next?


Now we're ready to start working on our deferred shader.



No comments:

Post a Comment