Pointing Projections

Spotlights represent a light that has position, direction, and perhaps an FOV and some kind of aspect ratio. Through projective texturing, we can make spotlights that have arbitrary light intensities, rather than relying on uniform values or shader functions to compute light intensity. That is all well and good for spotlights, but there are other forms of light that might want varying intensities.

It doesn't really make sense to vary the light intensity from a directional light. After all, the whole point of directional lights is that they are infinitely far away, so all of the light from them is uniform, in both intensity and direction.

Varying the intensity of a point light is a more reasonable possibility. We can vary the point light's intensity based on one of two possible parameters: the position of the light and the direction from the light towards a point in the scene. The latter seems far more useful; it represents a light that may cast more or less brightly in different directions.

To do this, what we need is a texture that we can effectively access via a direction. While there are ways to convert a 3D vector direction into a 2D texture coordinate, we will not use any of them. We will instead use a special texture type creates specifically for exactly this sort of thing.

The common term for this kind of texture is cube map, even though it is a texture rather than a mapping of a texture. A cube map texture is a texture where every mipmap level is 6 2D images, not merely one. Each of the 6 images represents one of the 6 faces of a cube. The texture coordinates for a cube map are a 3D vector direction; the texture sampling hardware selects which face to sample from and which texel to pick based on the direction.

It is important to know how the 6 faces of the cube map fit together. OpenGL defines the 6 faces based on the X, Y, and Z axes, in the positive and negative directions. This diagram explains the orientation of the S and T coordinate axes of each of the faces, relative to the direction of the faces in the cube.

Figure 17.9. Cube Map Face Orientation

Cube Map Face Orientation

This information is vital for knowing how to construct the various faces of a cube map. Notice that the four sides of the cube, not the top and bottom, are actually upside down. The T coordinate goes towards -Y rather than the more intuitive +Y.

To use a cube map to specify the light intensity changes for a point light, we simply need to do the following. First, we get the direction from the light to the surface point of interest. Then we use that direction to sample from the cube map. From there, everything is normal.

The issue is getting the direction from the light to the surface point. Before, a point light had no orientation, and this made sense. It cast light uniformly in all directions, so even if it had an orientation, you would never be able to tell it was there. Now that our light intensity can vary, the point light now needs to be able to orient the cube map.

The easiest way to handle this is a simple transformation trick. The position and orientation of the light represents a space. If we transform the position of objects into that space, then the direction from the light can easily be obtained. The light's position relative to itself is zero, after all. So we need to transform positions from some space into the light's space. We will see exactly how this is done momentarily.

Cube map point lights are implemented in the Cube Point Light project. This puts a fixed point light using a cube map in the middle of the scene. The orientation of the light can be changed with the right mouse button.

Figure 17.10. Cube Point Light

Cube Point Light

This cube texture has various different light arrangements on the different sides. One side even has green text on it. As before, you can use the G key to toggle the non-cube map lights off.

Pressing the 2 key switches to a texture that looks somewhat resembles a planetarium show. Pressing 1 switches back to the first texture.

Cube Texture Loading

We have seen how 2D textures get loaded over the course of 3 tutorials now, so we use GL Image's functions for creating a texture directly from ImageSet. Cube map textures require special handling, so let's look at this now.

Example 17.8. Cube Texture Loading

std::string filename(Framework::FindFileOrThrow(g_texDefs[tex].filename));
std::auto_ptr<glimg::ImageSet> pImageSet(glimg::loaders::dds::LoadFromFile(filename.c_str()));

glBindTexture(GL_TEXTURE_CUBE_MAP, g_lightTextures[tex]);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_BASE_LEVEL, 0);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAX_LEVEL, 0);

glimg::Dimensions dims = pImageSet->GetDimensions();
GLenum imageFormat = (GLenum)glimg::GetInternalFormat(pImageSet->GetFormat(), 0);

for(int face = 0; face < 6; ++face)
{
    glimg::SingleImage img = pImageSet->GetImage(0, 0, face);
    glCompressedTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face,
        0, imageFormat, dims.width, dims.height, 0,
        img.GetImageByteSize(), img.GetImageData());
}

glBindTexture(GL_TEXTURE_CUBE_MAP, 0);

The DDS format is one of the few image file formats that can actually store all of the faces of a cube map. Similarly, the glimg::ImageSet class can store cube map faces.

The first step after loading the cube map faces is to bind the texture to the GL_TEXTURE_CUBE_MAP texture binding target. Since this cube map is not mipmapped (yes, cube maps can have mipmaps), we set the base and max mipmap levels to zero. The call to glimg::GetInternalFormat is used to allow GL Image to tell us the OpenGL image format that corresponds to the format of the loaded texture data.

From there, we loop over the 6 faces of the texture, get the SingleImage for that face, and load each face into the OpenGL texture. For the moment, pretend the call to glCompressedTexImage2D is a call to glTexImage2D; they do similar things, but the final few parameters are different. It may seem odd to call a TexImage2D function when we are uploading to a cube map texture. After all, a cube map texture is a completely different texture type from 2D textures.

However, the TexImage family of functions specify the dimensionality of the image data they are allocating and uploading, not the specific texture type. This is a bit confusing; it's easiest to think of it as creating an Image of a given dimensionality. Since a cube map is simply 6 sets of 2D image images, it uses the TexImage2D functions to allocate the faces and mipmaps. Which face is specified by the first parameter.

OpenGL has six enumerators of the form GL_TEXTURE_CUBE_MAP_POSITIVE/NEGATIVE_X/Y/Z. These enumerators are ordered, starting with positive X, so we can loop through all of them by adding the numbers [0, 5] to the positive X enumerator. That is what we do above. The order of these enumerators is:

  1. POSITIVE_X

  2. NEGATIVE_X

  3. POSITIVE_Y

  4. NEGATIVE_Y

  5. POSITIVE_Z

  6. NEGATIVE_Z

This mirrors the order that the ImageSet stores them in (and stored in DDS files, for that matter).

The samplers for cube map textures also needs some adjustment:

glSamplerParameteri(g_samplers[0], GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glSamplerParameteri(g_samplers[0], GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glSamplerParameteri(g_samplers[0], GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);

Cube maps take 3D texture coordinates, so wrap modes must be specified for each of the three dimensions of texture coordinates. Since this cube map has no mipmaps, the filtering is simply set to GL_LINEAR.

Texture Compression

Now we will take a look at why we are using glCompressedTexImage2D. And that requires a discussion of image formats and sizes.

Images take up a lot of memory. And while disk space and even main memory are fairly generous these days, GPU memory is always at a premium. Especially if you have lots of textures and those textures are quite large. The smaller that texture data can be, the more and larger textures you can have in a complex scene.

The first stop for making this data smaller is to use a smaller image format. For example, the standard RGB color format stores each channel as an 8-bit unsigned integer. This is usually padded out to make it 4-byte aligned, or a fourth component (alpha) is added, making for an RGBA color. That's 32-bits per texel, which is what GL_RGBA8 specifies. A first pass for making this data smaller is to store it with fewer bits. OpenGL provides GL_RGB565 for those who do not need the fourth component, or GL_RGBA4 for those who do. Both of these use 16-bits per texel.

They both also can produce unpleasant visual artifacts for the textures. Plus, OpenGL does not allow such textures to be in the sRGB colorspace; there is no GL_SRGB565 format.

For files, this is a solved problem. There are a number of traditional compressed image formats: PNG, JPEG, GIF, etc. Some are lossless, meaning that the exact input image can be reconstructed. Others are lossy, which means that only an approximation of the image can be returned. Either way, these all formats have their benefits and downsides. But they are all better, in terms of visual quality and space storage, than using 16-bit per texel image formats.

They also have one other thing in common: they are absolutely terrible for textures, in terms of GPU hardware. These formats are designed to be decompressed all at once; you decompress the entire image when you want to see it. GPUs don't want to do that. GPUs generally access textures in pieces; they access certain sections of a mipmap level, then access other sections, etc. GPUs gain their performance by being incredibly parallel: multiple different invocations of fragment shaders can be running simultaneously. All of them can be accessing different textures and so forth.

Stopping that processes to decompress a 50KB PNG would pretty much destroy rendering performance entirely. These formats may be fine for storing files on disk. But they are simply not good formats for being stored compressed in graphics memory.

Instead, there are special formats designed specifically for compressing textures. These texture compression formats are designed specifically to be friendly for texture accesses. It is easy to find the exact piece of memory that stores the data for a specific texel. It takes no more than 64 bits of data to decompress any one texel. And so forth. These all combine to make texture compression formats useful for saving graphics card memory, while maintaining reasonable image quality.

The regular glTexImage2D function is not capable of directly uploading compressed texture data. The pixel transfer information, the last three parameters of glTexImage2D, is simply not appropriate for dealing with compressed texture data. Therefore, OpenGL uses a different function for uploading texture data that is already compressed.

glCompressedTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face,
    0, imageFormat, dims.width, dims.height, 0,
    img.GetImageByteSize(), img.GetImageData());

Instead of taking OpenGL enums that define what the format of the compressed data is, glCompressedTexImage2D's last two parameters are very simple. They specify how big the compressed image data is in bytes and provide a pointer to that image data. That is because glCompressedTexImage2D does not allow for format conversion; the format of the pixel data passed to it must exactly match what the image format says it is. This also means that the GL_UNPACK_ALIGNMENT has no effect on compressed texture uploads.

Cube Texture Space

Creating the cube map texture was just the first step. The next step is to do the necessary transformations. Recall that the goal is to transform the vertex positions into the space of the texture, defined relative to world space by a position and orientation. However, we ran into a problem previously, because the scene graph only provides a model-to-camera transformation matrix.

This problem still exists, and we will solve it in exactly the same way. We will generate a matrix that goes from camera space to our cube map light's space.

Example 17.9. View Camera to Light Cube Texture

glutil::MatrixStack lightProjStack;
lightProjStack.ApplyMatrix(glm::inverse(lightView));
lightProjStack.ApplyMatrix(glm::inverse(cameraMatrix));

g_lightProjMatBinder.SetValue(lightProjStack.Top());

glm::vec4 worldLightPos = lightView[3];
glm::vec3 lightPos = glm::vec3(cameraMatrix * worldLightPos);

g_camLightPosBinder.SetValue(lightPos);

This code is rather simpler than the prior time. Again reading bottom up, we transform by the inverse of the world-to-camera matrix, then we transform by the inverse of the light matrix. The lightView matrix is inverted because the matrix is ordinarily designed to go from light space to world space. So we invert it to get the world-to-light transform. The light's position in world space is taken similarly.

The vertex shader (cubeLight.vert) is about what you would expect:

lightSpacePosition = (cameraToLightProjMatrix * vec4(cameraSpacePosition, 1.0)).xyz;

The lightSpacePosition is output from the vertex shader and interpolated. Again we find that this interpolates just fine, so there is no need to do this transformation per-fragment.

The fragment shader code (cubeLight.frag) is pretty simple. First, we have to define our GLSL samplers:

uniform sampler2D diffuseColorTex;
uniform samplerCube lightCubeTex;

Because cube maps are a different texture type, they have a different GLSL sampler type as well. Attempting to use texture with the one type on a sampler that uses a different type results in unpleasantness. It's usually easy enough to keep these things straight, but it can be a source of errors or non-rendering.

The code that fetches from the cube texture is as follows:

PerLight currLight;
currLight.cameraSpaceLightPos = vec4(cameraSpaceProjLightPos, 1.0);
	
vec3 dirFromLight = normalize(lightSpacePosition);
currLight.lightIntensity =
    texture(lightCubeTex, dirFromLight) * 6.0f;

We simply normalize the light-space position, since the cube map's space has the light position at the origin. We then use the texture to access the cubemap, the same one we used for 2D textures. This is possible because GLSL overloads the texture based on the type of sampler. So when texture is passed a samplerCube, it expects a vec3 texture coordinate.

Fork me on GitHub