Further enhancing 2D sprites with 3D shader techniques

In my last post I wrote about how using an API like SFML meant that it was relatively easy to employ GLSL fragment shaders to create effects such as normal mapping on 2D sprites, thus giving them a 3D look. Since then I've been working on extending the technique to include specular highlighting which, as it turns out, is very closely linked to normal mapping. Originally I concentrated on creating self-shadows by calculating which of the normals in the normal map would be facing away from the light source and darkening them. As it turned out it didn't take much thought to extend the idea in the opposite direction and start brightening pixels whose normals faced the light. This isn't actually 100% accurate though, as any normals facing the light would reflect directly back at the source, meaning that any view other than directly behind the light is not going to see anything reflected. In fact a pixel's normal vector would have to be half way between the vector between the pixel and the light source, and the vector between the pixel and the view point. The amount of visible reflected light becomes smaller the closer the normal vector becomes to either of the other two vectors. After researching various shading techniques I decided that this wikipedia article best explains (to me at least) what I was trying to achieve and, rather helpfully, includes some example HLSL code to demonstrate how specular highlighting can be done. I was able to use this as a basis for specular highlighting in my procedural map shader, adding to the existing normal and reflection mapping features. In this post I'll attempt to explain in more detail exactly what goes on in the shader.

    Firstly the big advantage of working in 2D is that there are no vertex coordinates to worry about; there are no matrix transforms to consider as the projection to screen space is fixed. This means there is no need for a vertex shader, and that we can work directly with either the texture or fragment coordinates. The procedural map shader works mainly in texture coordinates as it is intended to be applied to individual sprites, but the light position is updated in fragment coordinates (relative to screen space) so that multiple sprites using the shader will look like they are affected by the same single light source. The shader has a series of uniform variables to provide programmable flexibility from SFML although most of them do have default values. These are:

//input textures
uniform sampler2D colourMap;
uniform sampler2D normalMap;
uniform sampler2D reflectMap;
uniform sampler2D specularMap;


the 4 samplers used to sample each map needed by the shader,

//light properties
uniform vec3 lightPosition = vec3(0.0);
uniform vec3 lightColour = vec3(0.6, 0.6, 0.6);
uniform float lightPower = 0.2;
uniform vec4 lightSpecColour = vec4(0.4, 0.4, 0.4, 1.0);
uniform float lightSpecPower = 0.08;


which define how the light affects any sprites drawn with the shader. These are uniform in case properties such as brightness or colour need to be updated by the game, but my demo project uses these values by default, with the exception of the light position which is updated with the mouse position each frame. I should note here that the vertical (Y axis) coordinate in GLSL are inverse to that of SFML's. Finally:

uniform vec2 reflectOffset;
uniform vec2 resolution = vec2(60.0, 102.0);
uniform bool invertBumpY = true;


The offset variable is used to calculate the sprite's position relative to the world and update the coordinates of the reflection map, so that the reflection appears to stay still when the sprite moves. This is done in the demo by passing the sprite's current coordinates, although these coordinates will need to be scaled if you are using an sf::View which scales the world space to screen space. The resolution variable is set to the resolution of the sprite to which the shader is applied, and is used when working out the sprite's position relative to the light source. Finally a boolean variable is used to optionally invert the Y (green) channel of the normal map, as some normal programs output normal maps differently to others. If your normal map appears to render oddly then try setting this to false (the output of SSBump required me to set this to true).
    Then we move on to the main function, the content of which can be broken down:
 
//sample our diffuse and normal maps
vec2 coord = gl_TexCoord[0].xy;
vec4 diffuseColour = texture2D(colourMap, coord);
vec3 normalColour = texture2D(normalMap, coord).rgb;

First we grab the current fragment of the diffuse (colour) map and the normal map, as the diffuseColour fragment will be worked on throughout the function, and the normalColour fragment needs to be converted to a normalised normal vector before anything else can be done, which is what happens next;

//get normal value from sample
normalColour.g = invertBumpY ? 1.0 - normalColour.g : normalColour.g;
vec3 normal = normalize(normalColour * 2.0 - 1.0);

inverting the green channel if necessary. Once this is done the reflection map is blended with the diffuse colour using the value of the alpha channel of the normal map. The red and green (X and Y) values of the normal vector are also used to slightly distort the reflection map to match the shape of the sprite, by adding them to the coordinates.

//mix reflection map with colour using normal map alpha
float blendVal = texture2D(normalMap, coord).a;
coord = mod(reflectOffset, resolution) / resolution; //add offset to coord
coord.y = 1.0 - coord.y; //invert the Y coordinate from SFML coordinates
vec3 reflectColour = texture2D(reflectMap, coord + (normal.rg * 2.0)).rgb; //adding normal distorts reflection
diffuseColour.rgb = mix(diffuseColour.rgb, reflectColour, blendVal);

Now the diffuse colour has been mixed with the reflection map the light position can be calculated and used to brighten or darken the diffuse colour depending on the direction of the fragment's normal vector. The light position is first converted from screen/fragment coordinates (0 - screen size in pixels) to normalised coordinates (0.0 - 1.0) relative to texture coordinates. The dot product of the normal vector and the light position vector will then reveal the intensity of the light reaching the diffuse map fragment. If the intensity is greater than 0 the fragment needs to be brightened using the half vector calculation described on the wikipedia page, combined with the value of the current fragment stored in the specular map:

//calculate the light vector
vec3 lightDir = vec3((lightPosition.xy - gl_FragCoord.xy) / resolution, lightPosition.z);
    

//calculate the colour intensity based on normal and add specular to pixel facing light
float colourIntensity = max(dot(normal, normalize(lightDir)), 0.0);
vec4 specular = vec4(0.0);
vec4 diffuse = vec4(0.0);
if(colourIntensity > 0.0)
{
    //vector half way between light and view direction
    vec3 halfVec = normalize(lightDir + vec3(0.5, 0.5, 0.5)); //fixed 2D view, so view is centred
    //get specular value from map
    vec4 specColour = vec4(texture2D(specularMap, gl_TexCoord[0].xy).rgb, 1.0);
    float specModifier = max(dot(normal, halfVec), 0.0);
    specular = pow(specModifier, lightSpecPower) * specColour * lightSpecColour;
    specular.a *= diffuseColour.a;
    diffuse = lightSpecColour * diffuseColour * colourIntensity;        
}

This gives us two new values for the current fragment stored in specular and diffuse. Finally we add any colour from the light source to the diffuse map, multiplied by the intensity value to darken any pixels whose normal vectors point away from the light:
 
diffuseColour.rgb += ((lightColour * lightPower) * colourIntensity);
diffuseColour.rgb *= diffuseColour.rgb;

before finally adding all the values together and outputting the final fragment.

gl_FragColor = clamp(specular + diffuse + diffuseColour, 0.0, 1.0);

Here is a short video of the updated demo program:



which you can download here in an archive which also contains the source for the shader. I should note that, while I'm happy with the current status of the shader, it is still not yet fully featured. It does not support multiple light sources, and will not take into account any transformations performed on the sprite in SFML. For instance when rotating a sprite with Sprite::rotate() you will need to rotate the vector between the light source and the sprite's origin in the opposite direction before updating the shader's lightPosition parameter. As an example, if you don't rotate the light position after rotating the sprite 180 degrees, a light source below the sprite will appear to light the top rather than the bottom. In the future I may add a rotation property to the shader, but the rotation can presently be calculated in SFML before updating the shader's parameters. The same is also true for any sprite scaling, which will require updating the resolution parameter of the shader.

Comments

Popular Posts