Chapter 8. Advanced Lighting and Texturing

There are two kinds of light—the glow that illumines and the glare that obscures.

James Thurber

At this point in this book, you may have written a couple simple OpenGL demos to impress your co-workers and family members. But, your app may need that extra little something to stand out from the crowd. This chapter goes over a small selection of more advanced techniques that can give your app an extra oomph.

The selection of effects dealt with in this chapter is by no means comprehensive. I encourage you to check out other graphics books, blogs, and academic papers to learn additional ways of dazzling your users. For example, this book does not cover rendering shadows; there are too many techniques for rendering shadows (or crude approximations thereof), so I can’t cover them while keeping this book concise. But, there’s plenty of information out there, and now that you know the fundamentals, it won’t be difficult to digest it.

This chapter starts off by detailing some of the more obscure texturing functionality in OpenGL ES 1.1. In a way, some of these features—specifically texture combiners, which allow textures to be combined in a variety of ways—are powerful enough to serve as a substitute for very simple fragment shaders.

The chapter goes on to cover normal maps and DOT3 lighting, useful for increasing the amount of perceived detail in your 3D models. (DOT3 simply refers to a three-component dot product; despite appearances, it’s not an acronym.) Next we discuss a technique for creating reflective surfaces that employs a special cube map texture, supported only in ES 2.0. We’ll then briefly cover anisotropic texturing, which improves texturing quality in some cases. Finally, we’ll go over an image-processing technique that adds a soft glow to the scene called bloom. The bloom effect may remind you of a camera technique used in cheesy 1980s soap operas, and I claim no responsibility if it compels you to marry your nephew in order to secure financial assistance for your father’s ex-lover.

Texture Environments under OpenGL ES 1.1

Multitexturing was briefly introduced in the previous chapter (Image Composition and a Taste of Multitexturing), but there’s a lot more to explain. See Figure 8-1 for a high-level overview of the iPhone’s texturing capabilities.

Note

This section doesn’t have much in the way of example code; if you’re not interested in the details of texture combination under OpenGL ES 1.1, skip to the next section (Bump Mapping and DOT3 Lighting).

Texture pipeline for OpenGL ES 1.1
Figure 8-1. Texture pipeline for OpenGL ES 1.1

Here are a few disclaimers regarding Figure 8-1. First, the diagram assumes that both texture stages are enabled; if stage 1 is disabled, the “previous color” gets passed on to become the “final color.” Second, the diagram shows only two texture stages. This is accurate for first- and second-generation devices, but newer devices have eight texture units.

In Figure 8-1, the “primary” color comes from the interpolation of per-vertex colors. Per-vertex colors are produced by lighting or set directly from the application using glColor4f or GL_COLOR_ARRAY.

The two lookup colors are the postfiltered texel colors, sampled from a particular texture image.

Each of the two texture environments is configured to combine its various inputs and produce an output color. The default configuration is modulation, which was briefly mentioned in Chapter 5; this means that the output color results from a per-component multiply of the previous color with the lookup color.

There are a whole slew of ways to configure each texture environment using the glTexEnv function. In my opinion, this is the worst function in OpenGL, and I’m thankful that it doesn’t exist in OpenGL ES 2.0. The expressiveness afforded by GLSL makes glTexEnv unnecessary.

glTexEnv has the following prototypes:

void glTexEnvi(GLenum target, GLenum pname, GLint param);
void glTexEnviv(GLenum target, GLenum pname, const GLint* params);
void glTexEnvf(GLenum target, GLenum pname, GLfloat param);
void glTexEnvfv(GLenum target, GLenum pname, const GLfloat* params);

Note

There are actually a couple more variants for fixed-point math, but I’ve omitted them since there’s never any reason to use fixed-point math on the iPhone. Because of its chip architecture, fixed-point numbers require more processing than floats.

The first parameter, target, is always set to GL_TEXTURE_ENV, unless you’re enabling point sprites as described in Rendering Confetti, Fireworks, and More: Point Sprites. The second parameter, pname, can be any of the following:

GL_TEXTURE_ENV_COLOR

Sets the constant color. As you’ll see later, this used only if the mode is GL_BLEND or GL_COMBINE.

GL_COMBINE_RGB

Sets up a configurable equation for the RGB component of color. Legal values of param are discussed later.

GL_COMBINE_ALPHA

Sets up a configurable equation for the alpha component of color. Legal values of param are discussed later.

GL_RGB_SCALE

Sets optional scale on the RGB components that takes place after all other operations. Scale can be 1, 2, or 4.

GL_ALPHA_SCALE

Sets optional scale on the alpha component that takes place after all other operations. Scale can be 1, 2, or 4.

GL_TEXTURE_ENV_MODE

Sets the mode of the current texture environment; the legal values of param are shown next.

If pname is GL_TEXTURE_ENV_MODE, then param can be any of the following:

GL_REPLACE

Set the output color equal to the lookup color:

OutputColor = LookupColor
GL_MODULATE

This is the default mode; it simply does a per-component multiply of the lookup color with the previous color:

OutputColor = LookupColor * PreviousColor
GL_DECAL

Use the alpha value of the lookup color to overlay it with the previous color. Specifically:

OutputColor = PreviousColor * (1 - LookupAlpha) + LookupColor * LookupAlpha
GL_BLEND

Invert the lookup color, then modulate it with the previous color, and then add the result to a scaled lookup color:

OutputColor = PreviousColor * (1 - LookupColor) + LookupColor * ConstantColor
GL_ADD

Use per-component addition to combine the previous color with the lookup color:

OutputColor = PreviousColor + LookupColor
GL_COMBINE

Generate the RGB outputs in the manner configured by GL_COMBINE_RGB, and generate the alpha output in the manner configured by GL_COMBINE_ALPHA.

The two texture stages need not have the same mode. For example, the following snippet sets the first texture environment to GL_REPLACE and the second environment to GL_MODULATE:

glActiveTexture(GL_TEXTURE0);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, myFirstTextureObject);
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);

glActiveTexture(GL_TEXTURE1);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, mySecondTextureObject);
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);

Texture Combiners

If the mode is set to GL_COMBINE, you can set up two types of combiners: the RGB combiner and the alpha combiner. The former sets up the output color’s RGB components; the latter configures its alpha value.

Each of the two combiners needs to be set up using at least five additional (!) calls to glTexEnv. One call chooses the arithmetic operation (addition, subtraction, and so on), while the other four calls set up the arguments to the operation. For example, here’s how you can set up the RGB combiner of texture stage 0:

glActiveTexture(GL_TEXTURE0);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, myTextureObject);
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_COMBINE);

// Tell OpenGL which arithmetic operation to use: 
glTexEnvi(GL_TEXTURE_ENV, GL_COMBINE_RGB, <operation>);

// Set the first argument:
glTexEnvi(GL_TEXTURE_ENV, GL_SRC0_RGB, <source0>);
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND0_RGB, <operand0>);

// Set the second argument:
glTexEnvi(GL_TEXTURE_ENV, GL_SRC1_RGB, <source1>);
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND1_RGB, <operand1>);

Setting the alpha combiner is done in the same way; just swap the RGB suffix with ALPHA, like this:

// Tell OpenGL which arithmetic operation to use: 
glTexEnvi(GL_TEXTURE_ENV, GL_COMBINE_ALPHA, <operation>);

// Set the first argument:
glTexEnvi(GL_TEXTURE_ENV, GL_SRC0_ALPHA, <source0>);
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND0_ALPHA, <operand0>);

// Set the second argument:
glTexEnvi(GL_TEXTURE_ENV, GL_SRC1_ALPHA, <source1>);
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND1_ALPHA, <operand1>);

The following is the list of arithmetic operations you can use for RGB combiners (in other words, the legal values of <operation> when pname is set to GL_COMBINE_RGB):

GL_REPLACE
OutputColor = Arg0
GL_MODULATE
OutputColor = Arg0 * Arg1
GL_ADD
OutputColor = Arg0 + Arg1
GL_ADD_SIGNED
OutputColor = Arg0 + Arg1 - 0.5
GL_INTERPOLATE
OutputColor = Arg0 ∗ Arg2 + Arg1 ∗ (1 − Arg2)
GL_SUBTRACT
OutputColor = Arg0 - Arg1
GL_DOT3_RGB
OutputColor = 4 * Dot(Arg0 - H, Arg1 - H) where H = (0.5, 0.5, 0.5)
GL_DOT3_RGBA
OutputColor = 4 * Dot(Arg0 - H, Arg1 - H) where H = (0.5, 0.5, 0.5)

Note that GL_DOT3_RGB and GL_DOT3_RGBA produce a scalar rather than a vector. With GL_DOT3_RGB, that scalar is duplicated into each of the three RGB channels of the output color, leaving alpha untouched. With GL_DOT3_RGBA, the resulting scalar is written out to all four color components. The dot product combiners may seem rather strange, but you’ll see how they come in handy in the next section.

GL_INTERPOLATE actually has three arguments. As you’d expect, setting up the third argument works the same way as setting up the others; you use GL_SRC2_RGB and GL_OPERAND2_RGB.

For alpha combiners (GL_COMBINE_ALPHA), the list of legal arithmetic operations is the same as RGB combiners, except that the two dot-product operations are not supported.

The <source> arguments in the preceding code snippet can be any of the following:

GL_TEXTURE

Use the lookup color.

GL_CONSTANT

Use the constant color that’s set with GL_TEXTURE_ENV_COLOR.

GL_PRIMARY_COLOR

Use the primary color (this is the color that comes from lighting or glColor4f).

GL_PREVIOUS

Use the output of the previous texture stage. For stage 0, this is equivalent to GL_PRIMARY_COLOR.

For RGB combiners, the <operand> arguments can be any of the following:

GL_SRC_COLOR

Pull the RGB components from the source color.

GL_ONE_MINUS_SRC_COLOR

Use the inverted RGB components from the source color.

GL_SRC_ALPHA

Pull the alpha component from the source color.

GL_ONE_MINUS_SRC_ALPHA

Use the inverted alpha component from the source color.

For alpha combiners, only the last two of the preceding list can be used.

By now, you can see that combiners effectively allow you to set up a set of equations, which is something that’s much easier to express with a shading language!

All the different ways of calling glTexEnv are a bit confusing, so I think it’s best to go over a specific example in detail; we’ll do just that in the next section, with an in-depth explanation of the DOT3 texture combiner and its equivalent in GLSL.

Bump Mapping and DOT3 Lighting

If you’d like to render an object with fine surface detail but you don’t want to use an incredibly dense mesh of triangles, there’s a technique called bump mapping that fits the bill. It’s also called normal mapping, since it works by varying the surface normals to affect the lighting. You can use this technique to create more than mere bumps; grooves or other patterns can be etched into (or raised from) a surface. Remember, a good graphics programmer thinks like a politician and uses lies to her advantage! Normal mapping doesn’t actually affect the geometry at all. This is apparent when you look along the silhouette of a normal-mapped object; it appears flat. See Figure 8-2.

Bumpy demo with OpenGL ES 2.0
Figure 8-2. Bumpy demo with OpenGL ES 2.0

You can achieve this effect with either OpenGL ES 2.0 or OpenGL ES 1.1, although bump mapping under 1.1 is much more limited.

Either approach requires the use of a normal map, which is a texture that contains normal vectors (XYZ components) rather than colors (RGB components). Since color components are, by definition, non-negative, a conversion needs to occur to represent a vector as a color:

vec3 TransformedVector = (OriginalVector + vec3(1, 1, 1)) / 2

The previous transformation simply changes the range of each component from [–1, +1] to [0, +1].

Representing vectors as colors can sometimes cause problems because of relatively poor precision in the texture format. On some platforms, you can work around this with a high-precision texture format. At the time of this writing, the iPhone does not support high-precision formats, but I find that standard 8-bit precision is good enough in most scenarios.

Note

Another way to achieve bump mapping with shaders is to cast aside the normal map and opt for a procedural approach. This means doing some fancy math in your shader. While procedural bump mapping is fine for simple patterns, it precludes artist-generated content.

There are a number of ways to generate a normal map. Often an artist will create a height map, which is a grayscale image where intensity represents surface displacement. The height map is then fed into a tool that builds a terrain from which the surface normals can be extracted (conceptually speaking).

PVRTexTool (see The PowerVR SDK and Low-Precision Textures) is such a tool. If you invoke it from a terminal window, simply add -b to the command line, and it generates a normal map. Other popular tools include Ryan Clark’s crazybump application and NVIDIA’s Melody, but neither of these is supported on Mac OS X at the time of this writing. For professional artists, Pixologic’s Z-Brush is probably the most sought-after tool for normal map creation (and yes, it’s Mac-friendly). For an example of a height map and its resulting normal map, see the left two panels in Figure 8-3.

Left two panels: height map and tangent-space normals; right panel: object-space normals for the Klein bottle
Figure 8-3. Left two panels: height map and tangent-space normals; right panel: object-space normals for the Klein bottle

An important factor to consider with normal maps is the “space” that they live in. Here’s a brief recap from Chapter 2 concerning the early life of a vertex:

  1. Vertex positions are stored in a VBO (or vertex array) in object space.

  2. Objects are placed into the scene using a model matrix, which takes them into world space.

  3. The vertices are then transformed by a view matrix, which takes them into eye space.

For bog standard lighting (not bump mapped), normals are sent to OpenGL in object space. However, the normal maps that get generated by tools like crazybump are defined in tangent space (also known as surface local space). Tangent space is the 2D universe that textures live in; if you were to somehow “unfold” your object and lay it flat on a table, you’d see what tangent space looks like.

Another tidbit to remember from an earlier chapter is that OpenGL takes object-space normals and transforms them into eye space using the inverse-transpose of the model-view matrix (Normal Transforms Aren’t Normal). Here’s the kicker: transformation of the normal vector can actually be skipped in certain circumstances. If your light source is infinitely distant, you can simply perform the lighting in object space! Sure, the lighting is a bit less realistic, but when has that stopped us?

So, normal maps are (normally) defined in tangent space, but lighting is (normally) performed in eye space or object space. How do we handle this discrepancy? With OpenGL ES 2.0, we can revise the lighting shader so that it transforms the normals from tangent space to object space. With OpenGL ES 1.1, we’ll need to transform the normal map itself, as depicted in the rightmost panel in Figure 8-3. More on this later; first we’ll go over the shader-based approach since it can give you a better understanding of what’s going on.

Another Foray into Linear Algebra

Before writing any code, we need to figure out how the shader should go about transforming the normals from tangent space to object space. In general, we’ve been using matrices to make transformations like this. How can we come up with the right magical matrix?

Any coordinate system can be defined with a set of basis vectors. The set is often simply called a basis. The formal definition of basis involves phrases like “linearly independent spanning set,” but I don’t want you to run away in abject terror, so I’ll just give an example.

For 3D space, we need three basis vectors, one for each axis. The standard basis is the space that defines the Cartesian coordinate system that we all know and love:

  • Basis Vector A: (1, 0, 0)

  • Basis Vector B: (0, 1, 0)

  • Basis Vector C: (0, 0, 1)

Any set of unit-length vectors that are all perpendicular to each other is said to be orthonormal. Turns out that there’s an elegant way to transform a vector from any orthonormal basis to the standard basis. All you need to do is create a matrix by filling in each row with a basis vector:

Another Foray into Linear Algebra

If you prefer column-vector notation, then the basis vectors form columns rather than rows:

Another Foray into Linear Algebra

In any case, we now have the magic matrix for transforming normals! Incidentally, basis vectors can also be used to derive a matrix for general rotation around an arbitrary axis. Basis vectors are so foundational to linear algebra that mathematicians are undoubtedly scoffing at me for not covering them much earlier. I wanted to wait until a practical application cropped up—which brings us back to bump mapping.

Generating Basis Vectors

So, our bump mapping shader will need three basis vectors to transform the normal map’s values from tangent space to object space. Where can we get these three basis vectors? Recall for a moment the ParametricSurface class that was introduced early in this book. In The Math Behind Normals, the following pseudocode was presented:

p = Evaluate(s, t)
u = Evaluate(s + ds, t) - p
v = Evaluate(s, t + dt) - p
n = Normalize(u × v)

The three vectors u, v, and n are all perpendicular to each other—perfect for forming an orthonormal basis! The ParametricSurface class already computes n for us, so all we need to do is amend it to write out one of the tangent vectors. Either u or v will work fine; there’s no need to send both because the shader can easily compute the third basis vector using a cross product. Take a look at Example 8-1; for a baseline, this uses the parametric surface code that was first introduced in Chapter 3 and enhanced in subsequent chapters. New lines are highlighted in bold.

Example 8-1. Tangent support in ParametricSurface.hpp
void ParametricSurface::GenerateVertices(vector<float>& vertices,
                                         unsigned char flags) const
{
    int floatsPerVertex = 3;
    if (flags & VertexFlagsNormals)
        floatsPerVertex += 3;
    if (flags & VertexFlagsTexCoords)
        floatsPerVertex += 2;
    if (flags & VertexFlagsTangents)
        floatsPerVertex += 3;

    vertices.resize(GetVertexCount() * floatsPerVertex);
    float* attribute = &vertices[0];

    for (int j = 0; j < m_divisions.y; j++) {
        for (int i = 0; i < m_divisions.x; i++) {

            // Compute Position
            vec2 domain = ComputeDomain(i, j);
            vec3 range = Evaluate(domain);
            attribute = range.Write(attribute);

            // Compute Normal
            if (flags & VertexFlagsNormals) {
                ...
            }
            
            // Compute Texture Coordinates
            if (flags & VertexFlagsTexCoords) {
                ...
            }
            
            // Compute Tangent
            if (flags & VertexFlagsTangents) {
                float s = i, t = j;
                vec3 p = Evaluate(ComputeDomain(s, t));
                vec3 u = Evaluate(ComputeDomain(s + 0.01f, t)) - p;
                if (InvertNormal(domain))
                   u = -u;
                attribute = u.Write(attribute);
            }
        }
    }
}

Normal Mapping with OpenGL ES 2.0

Let’s crack some knuckles and write some shaders. A good starting point is the pair of shaders we used for pixel-based lighting in Chapter 4. I’ve repeated them here (Example 8-2), with uniform declarations omitted for brevity.

Example 8-2. Per-pixel lighting vertex and fragment shaders
attribute vec4 Position;
attribute vec3 Normal;

varying mediump vec3 EyespaceNormal;

// Vertex Shader
void main(void)
{
    EyespaceNormal = NormalMatrix * Normal;
    gl_Position = Projection * Modelview * Position;
}

// Fragment Shader
void main(void)
{
    highp vec3 N = normalize(EyespaceNormal);
    highp vec3 L = LightVector;
    highp vec3 E = EyeVector;
    highp vec3 H = normalize(L + E);
    highp float df = max(0.0, dot(N, L));
    highp float sf = max(0.0, dot(N, H));
    sf = pow(sf, Shininess);

    lowp vec3 color = AmbientMaterial + df 
      * DiffuseMaterial + sf * SpecularMaterial;
    gl_FragColor = vec4(color, 1);
}

To extend this to support bump mapping, we’ll need to add new attributes for the tangent vector and texture coordinates. The vertex shader doesn’t need to transform them; we can leave that up to the pixel shader. See Example 8-3.

Example 8-3. Vertex shader for the Bumpy sample
attribute vec4 Position;
attribute vec3 Normal;
attribute vec3 Tangent;
attribute vec2 TextureCoordIn;

uniform mat4 Projection;
uniform mat4 Modelview;

varying vec2 TextureCoord;
varying vec3 ObjectSpaceNormal;
varying vec3 ObjectSpaceTangent;

void main(void)
{
    ObjectSpaceNormal = Normal;
    ObjectSpaceTangent = Tangent;
    gl_Position = Projection * Modelview * Position;
    TextureCoord = TextureCoordIn;
}

Before diving into the fragment shader, let’s review what we’ll be doing:

  1. Extract a perturbed normal from the normal map, transforming it from [0, +1] to [–1, +1].

  2. Create three basis vectors using the normal and tangent vectors that were passed in from the vertex shader.

  3. Perform a change of basis on the perturbed normal to bring it to object space.

  4. Execute the same lighting algorithm that we’ve used in the past, but use the perturbed normal.

Now we’re ready! See Example 8-4.

Warning

When computing tangentSpaceNormal, you might need to swap the normal map’s x and y components, just like we did in Example 8-4. This may or may not be necessary, depending on the coordinate system used by your normal map generation tool.

Example 8-4. Fragment shader for the Bumpy sample
varying mediump vec2 TextureCoord;
varying mediump vec3 ObjectSpaceNormal;
varying mediump vec3 ObjectSpaceTangent;

uniform highp vec3 AmbientMaterial;
uniform highp vec3 DiffuseMaterial;
uniform highp vec3 SpecularMaterial;
uniform highp float Shininess;
uniform highp vec3 LightVector;
uniform highp vec3 EyeVector;

uniform sampler2D Sampler;

void main(void)
{
    // Extract the perturbed normal from the texture:
    highp vec3 tangentSpaceNormal =
      texture2D(Sampler, TextureCoord).yxz * 2.0 - 1.0;

    // Create a set of basis vectors:
    highp vec3 n = normalize(ObjectSpaceNormal);
    highp vec3 t = normalize(ObjectSpaceTangent);
    highp vec3 b = normalize(cross(n, t));

    // Change the perturbed normal from tangent space to object space:
    highp mat3 basis = mat3(n, t, b);
    highp vec3 N = basis * tangentSpaceNormal;
    
    // Perform standard lighting math:
    highp vec3 L = LightVector;
    highp vec3 E = EyeVector;
    highp vec3 H = normalize(L + E);
    highp float df = max(0.0, dot(N, L));
    highp float sf = max(0.0, dot(N, H));
    sf = pow(sf, Shininess);

    lowp vec3 color = AmbientMaterial + df 
      * DiffuseMaterial + sf * SpecularMaterial;
    gl_FragColor = vec4(color, 1);
}

We’re not done just yet, though—since the lighting math operates on a normal vector that lives in object space, the LightVector and EyeVector uniforms that we pass in from the application need to be in object space too. To transform them from world space to object space, we can simply multiply them by the model matrix using our C++ vector library. Take care not to confuse the model matrix with the model-view matrix; see Example 8-5.

Example 8-5. Render() method for the Bumpy sample (OpenGL ES 2.0)
void RenderingEngine::Render(float theta) const
{
    // Render the background image:
    ...

    const float distance = 10;
    const vec3 target(0, 0, 0);
    const vec3 up(0, 1, 0);
    const vec3 eye = vec3(0, 0, distance);
    const vec3 view = mat4::LookAt(eye, target, up);
    const mat4 model = mat4::RotateY(theta);
    const mat4 modelview = model * view;

    const vec4 lightWorldSpace = vec4(0, 0, 1, 1);
    const vec4 lightObjectSpace = model * lightWorldSpace;

    const vec4 eyeWorldSpace(0, 0, 1, 1);
    const vec4 eyeObjectSpace = model * eyeWorldSpace;

    glUseProgram(m_bump.Program);
    glUniform3fv(m_bump.Uniforms.LightVector, 1, 
                 lightObjectSpace.Pointer());
    glUniform3fv(m_bump.Uniforms.EyeVector, 1, eyeObjectSpace.Pointer());
    glUniformMatrix4fv(m_bump.Uniforms.Modelview, 1, 
                       0, modelview.Pointer());
    glBindTexture(GL_TEXTURE_2D, m_textures.TangentSpaceNormals);
    
    // Render the Klein bottle:
    ...
}

Normal Mapping with OpenGL ES 1.1

You might be wondering why we used object-space lighting for shader-based bump mapping, rather than eye-space lighting. After all, eye-space lighting is what was presented way back in Chapter 4 as the “standard” approach. It’s actually fine to perform bump map lighting in eye space, but I wanted to segue to the fixed-function approach, which does require object space!

Note

Another potential benefit to lighting in object space is performance. I’ll discuss this more in the next chapter.

Earlier in the chapter, I briefly mentioned that OpenGL ES 1.1 requires the normal map itself to be transformed to object space (depicted in the far-right panel in Figure 8-3). If it were transformed it to eye space, then we’d have to create a brand new normal map every time the camera moves. Not exactly practical!

The secret to bump mapping with fixed-function hardware lies in a special texture combiner operation called GL_DOT3_RGB. This technique is often simply known as DOT3 lighting. The basic idea is to have the texture combiner generate a gray color whose intensity is determined by the dot product of its two operands. This is sufficient for simple diffuse lighting, although it can’t produce specular highlights. See Figure 8-4 for a screenshot of the Bumpy app with OpenGL ES 1.1.

Here’s the sequence of glTexEnv calls that sets up the texturing state used to generate Figure 8-4:

glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_COMBINE);
glTexEnvi(GL_TEXTURE_ENV, GL_COMBINE_RGB, GL_DOT3_RGB);
glTexEnvi(GL_TEXTURE_ENV, GL_SRC0_RGB, GL_PRIMARY_COLOR);
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND0_RGB, GL_SRC_COLOR);
glTexEnvi(GL_TEXTURE_ENV, GL_SRC1_RGB, GL_TEXTURE);
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND1_RGB, GL_SRC_COLOR);

The previous code snippet tells OpenGL to set up an equation like this:

Normal Mapping with OpenGL ES 1.1

Where:

  • C is the per-vertex color, set with glColor4f. This represents the light direction.

  • T is the texel color. This represents the perturbed normal.

  • L is the luminance value of the final color. This represents the diffuse factor.

  • H is (½, ½, ½).

Bumpy app with OpenGL ES 1.1
Figure 8-4. Bumpy app with OpenGL ES 1.1

Curious about the H offset and the final multiply-by-four? Remember, we had to transform our normal vectors from unit space to color space:

vec3 TransformedVector = (OriginalVector + vec3(1, 1, 1)) / 2

The H offset and multiply-by-four simply puts the final result back into unit space. Since this assumes that both vectors have been transformed in the previous manner, take care to transform the light position. Here’s the relevant snippet of application code, once again leveraging our C++ vector library:

vec4 lightWorldSpace = vec4(0, 0, 1, 0);
vec4 lightObjectSpace = modelMatrix * lightWorldSpace;    
lightObjectSpace = (lightObjectSpace + vec4(1, 1, 1, 0)) * 0.5f;

glColor4f(lightObjectSpace.x,
          lightObjectSpace.y,
          lightObjectSpace.z, 1);

The result from DOT3 lighting is often modulated with a second texture stage to produce a final color that’s nongray. Note that DOT3 lighting is basically performing per-pixel lighting but without the use of shaders!

Generating Object-Space Normal Maps

Perhaps the most awkward aspect of DOT3 lighting is that it requires you to somehow create a normal map in object space. Some generator tools don’t know what your actual geometry looks like; these tools take only a simple heightfield for input, so they can generate the normals only in tangent space.

The trick I used for the Klein bottle was to use OpenGL ES 2.0 as part of my “art pipeline,” even though the final application used only OpenGL ES 1.1. By running a modified version of the OpenGL ES 2.0 demo and taking a screenshot, I obtained an object-space normal map for the Klein bottle. See Figure 8-5.

Object space normals (before cropping)
Figure 8-5. Object space normals (before cropping)

Examples 8-6 and 8-7 show the shaders for this. Note that the vertex shader ignores the model-view matrix and the incoming vertex position. It instead uses the incoming texture coordinate to determine the final vertex position. This effectively “unfolds” the object. The Distance, Scale, and Offset constants are used to center the image on the screen. (I also had to do some cropping and scaling on the final image to make it have power-of-two dimensions.)

Example 8-6. Vertex shader for the object-space generator

attribute vec3 Normal;
attribute vec3 Tangent;
attribute vec2 TextureCoordIn;

uniform mat4 Projection;

varying vec2 TextureCoord;
varying vec3 ObjectSpaceNormal;
varying vec3 ObjectSpaceTangent;

const float Distance = 10.0;
const vec2 Offset = vec2(0.5, 0.5);
const vec2 Scale = vec2(2.0, 4.0);

void main(void)
{
    ObjectSpaceNormal = Normal;
    ObjectSpaceTangent = Tangent;

    vec4 v = vec4(TextureCoordIn - Offset, -Distance, 1);
    gl_Position = Projection * v;
    gl_Position.xy *= Scale;
    
    TextureCoord = TextureCoordIn;
}

The fragment shader is essentially the same as what was presented in Normal Mapping with OpenGL ES 2.0, but without the lighting math.

Example 8-7. Fragment shader for the object-space generator

varying mediump vec2 TextureCoord;
varying mediump vec3 ObjectSpaceNormal;
varying mediump vec3 ObjectSpaceTangent;

uniform sampler2D Sampler;

void main(void)
{
    // Extract the perturbed normal from the texture:
    highp vec3 tangentSpaceNormal = 
      texture2D(Sampler, TextureCoord).yxz * 2.0 - 1.0;

    // Create a set of basis vectors:
    highp vec3 n = normalize(ObjectSpaceNormal);
    highp vec3 t = normalize(ObjectSpaceTangent);
    highp vec3 b = normalize(cross(n, t));

       // Change the perturbed normal from tangent space to object space:
    highp mat3 basis = mat3(n, t, b);
    highp vec3 N = basis * tangentSpaceNormal;
    
    // Transform the normal from unit space to color space:
    gl_FragColor = vec4((N + 1.0) * 0.5, 1);
}

Reflections with Cube Maps

You might recall a technique presented in Chapter 6, where we rendered an upside-down object to simulate reflection. This was sufficient for reflecting a limited number of objects onto a flat plane, but if you’d like the surface of a 3D object to reflect a richly detailed environment, as shown in Figure 8-6, a cube map is required. Cube maps are special textures composed from six individual images: one for each of the six axis-aligned directions in 3D space. Cube maps are supported only in OpenGL ES 2.0.

Reflection sample
Figure 8-6. Reflection sample

Cube maps are often visualized using a cross shape that looks like an unfolded box, as shown in Figure 8-7.

Cube map cross of the Minnehaha Falls (courtesy of Edward Fink)
Figure 8-7. Cube map cross of the Minnehaha Falls (courtesy of Edward Fink)

The cross shape is for the benefit of humans only; OpenGL does expect it when you give it the image data for a cube map. Rather, it requires you to upload each of the six faces individually, like this:

glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X, mip, format, 
             w, h, 0, format, type, data[0]);
glTexImage2D(GL_TEXTURE_CUBE_MAP_NEGATIVE_X, mip, format,
             w, h, 0, format, type, data[1]);
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_Y, mip, format, 
             w, h, 0, format, type, data[2]);
glTexImage2D(GL_TEXTURE_CUBE_MAP_NEGATIVE_Y, mip, format,
             w, h, 0, format, type, data[3]);
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_Z, mip, format,
             w, h, 0, format, type, data[4]);
glTexImage2D(GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, mip, format,
             w, h, 0, format, type, data[5]);

Note that, for the first time, we’re using a texture target other than GL_TEXTURE_2D. This can be a bit confusing because the function call name still has the 2D suffix. It helps to think of each face as being 2D, although the texture object itself is not.

The enumerants for the six faces have contiguous values, so it’s more common to upload the faces of a cube map using a loop. For an example of this, see Example 8-8, which creates and populates a complete mipmapped cube map.

Example 8-8. CreateCubemap function
GLuint CreateCubemap(GLvoid** faceData, int size, GLenum format, GLenum type)
{
    GLuint textureObject;
    glGenTextures(1, &textureObject);
    glBindTexture(GL_TEXTURE_CUBE_MAP, textureObject);
    for (int f = 0; f < 6; ++f) {
        GLenum face = GL_TEXTURE_CUBE_MAP_POSITIVE_X + f;
        glTexImage2D(face, 0, format, size, size, 0, format, type, faceData[f]);
    }
    glTexParameteri(GL_TEXTURE_CUBE_MAP, 
                    GL_TEXTURE_MIN_FILTER, 
                    GL_LINEAR_MIPMAP_LINEAR); 
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glGenerateMipmap(GL_TEXTURE_CUBE_MAP);
    return textureObject;
}

Note

Example 8-8 is part of the rendering engine in a sample app in this book’s downloadable source code (see How to Contact Us).

In Example 8-8, the passed-in size parameter is the width (or height) of each cube map face. Cube map faces must be square. Additionally, on the iPhone, they must have a size that’s a power-of-two.

Example 8-9 shows the vertex shader that can be used for cube map reflection.

Example 8-9. Vertex shader (cube map sample)
attribute vec4 Position;
attribute vec3 Normal;

uniform mat4 Projection;
uniform mat4 Modelview;
uniform mat3 Model;
uniform vec3  EyePosition;

varying vec3 ReflectDir;

void main(void)
{
    gl_Position = Projection * Modelview * Position;
    
    // Compute eye direction in object space:
    mediump vec3 eyeDir = normalize(Position.xyz - EyePosition);

    // Reflect eye direction over normal and transform to world space:
    ReflectDir = Model * reflect(eyeDir, Normal);
}

Newly introduced in Example 8-9 is GLSL’s built-in reflect function, which is defined like this:

float reflect(float I, float N)
{
    return I - 2.0 * dot(N, I) * N;
}

N is the surface normal; I is the incident vector, which is the vector that strikes the surface at the point of interest (see Figure 8-8).

The GLSL “reflect” function
Figure 8-8. The GLSL “reflect” function

Note

Cube maps can also be used for refraction, which is useful for creating glass or other transparent media. GLSL provides a refract function to help with this.

The fragment shader for our cube mapping example is fairly simple; see Example 8-10.

Example 8-10. Fragment shader (cube map sample)
varying mediump vec3 ReflectDir;

uniform samplerCube Sampler;

void main(void)
{
    gl_FragColor = textureCube(Sampler, ReflectDir);
}

Newly introduced in Example 8-10 is a new uniform type called samplerCube. Full-blown desktop OpenGL has many sampler types, but the only two sampler types supported on the iPhone are samplerCube and sampler2D. Remember, when setting a sampler from within your application, set it to the stage index, not the texture handle!

The sampler function in Example 8-10 is also new: textureCube differs from texture2D in that it takes a vec3 texture coordinate rather than a vec2. You can think of it as a direction vector emanating from the center of a cube. OpenGL finds which of the three components have the largest magnitude and uses that to determine which face to sample from.

A common gotcha with cube maps is incorrect face orientation. I find that the best way to test for this issue is to render a sphere with a simplified version of the vertex shader that does not perform true reflection:

//ReflectDir = Model * reflect(eyeDir, Normal);
ReflectDir = Model * Position.xyz; // Test the face orientation. 

Using this technique, you’ll easily notice seams if one of your cube map faces needs to be flipped, as shown on the left in Figure 8-9. Note that only five faces are visible at a time, so I suggest testing with a negated Position vector as well.

From left to right: incorrect face orientation, corrected faces, corrected faces with reflection
Figure 8-9. From left to right: incorrect face orientation, corrected faces, corrected faces with reflection

Render to Cube Map

Instead of using a presupplied cube map texture, it’s possible to generate a cube map texture in real time from the 3D scene itself. This can be done by rerendering the scene six different times, each time using a different model-view matrix. Recall the function call that attached an FBO to a texture, first presented in A Super Simple Sample App for Supersampling:

GLenum attachment = GL_COLOR_ATTACHMENT0;
GLenum textureTarget = GL_TEXTURE_2D;
GLuint textureHandle = myTextureObject;
GLint mipmapLevel = 0;
glFramebufferTexture2D(GL_FRAMEBUFFER, attachment, 
                       textureTarget, textureHandle, mipmapLevel);

The textureTarget parameter is not limited to GL_TEXTURE_2D; it can be any of the six face enumerants (GL_TEXTURE_CUBE_MAP_POSITIVE_X and so on). See Example 8-11 for a high-level overview of a render method that draws a 3D scene into a cube map. (This code is hypothetical, not used in any samples in the book’s downloadable source code.)

Example 8-11. Rendering to a cube map
glBindFramebuffer(GL_FRAMEBUFFER, fboHandle);
glViewport(0, 0, fboWidth, fboHeight);

for (face = 0; face < 6; face++) {

    // Change the FBO attachment to the current face: 
    GLenum textureTarget = GL_TEXTURE_CUBE_MAP_POSITIVE_X + face;
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, 
                           textureTarget, textureHandle, 0);

    // Set the model-view matrix to point toward the current face:
    ...

    // Render the scene:
    ...
}

Warning

Rendering to a cube map texture is supported only in iPhone OS 3.1 and newer.

Anisotropic Filtering: Textures on Steroids

An issue with standard bilinear texture filtering is that it samples the texture using the same offsets, regardless of how the primitive is oriented on the screen. Bilinear filtering samples the texture four times across a 2×2 square of texels; mipmapped filtering makes a total of eight samples (2×2 on one mipmap level, 2×2 on another). The fact that these methods sample across a uniform 2×2 square can be a bit of a liability.

For example, consider a textured primitive viewed at a sharp angle, such as the grassy ground plane in the Holodeck sample from Chapter 6. The grass looks blurry, even though the texture is quite clear. Figure 8-10 shows a zoomed-in screenshot of this.

Isotropic versus anisotropic filtering
Figure 8-10. Isotropic versus anisotropic filtering

A special type of filtering scheme called anisotropic filtering can alleviate blurriness with near edge-on primitives. Anisotropic filtering dynamically adjusts its sampling distribution depending on the orientation of the surface. Anisotropic is a rather intimidating word, so it helps to break it down. Traditional bilinear filtering is isotropic, meaning “uniform in all dimensions”; iso is Greek for “equal,” and tropos means “direction” in this context.

Anisotropic texturing is made available via the GL_EXT_texture_filter_anisotropic extension. Strangely, at the time of this writing, this extension is available only on older iPhones. I strongly suggest checking for support at runtime before making use of it. Flip back to Dealing with Size Constraints to see how to check for extensions at runtime.

Note

Even if your device does not support the anisotropic extension, it’s possible to achieve the same effect in a fragment shader that leverages derivatives (discussed in Smoothing and Derivatives).

The anisotropic texturing extension adds a new enumerant for passing in to glTexParameter:

glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, 2.0f);

The GL_TEXTURE_MAX_ANISOTROPY_EXT constant sets the maximum degree of anisotropy; the higher the number, the more texture lookups are performed. Currently, Apple devices that support this extension have a maximum value of 2.0, but you should query it at runtime, as shown in Example 8-12.

Example 8-12. Enabling anisotropic filtering
GLfloat maxAniso;
glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &maxAniso);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, maxAniso);
glTexParameteri(GL_TEXTURE_2D, 
                GL_TEXTURE_MIN_FILTER, 
                GL_LINEAR_MIPMAP_LINEAR); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

For highest quality, you’ll want to use this anisotropic filtering in concert with mipmapping. Take care with this extension; the additional texture lookups can incur a loss in performance.

Image-Processing Example: Bloom

Whenever I watch a classic Star Trek episode from the 1960s, I always get a good laugh when a beautiful female (human or otherwise) speaks into the camera; the image invariably becomes soft and glowy, as though viewers need help in understanding just how feminine she really is. Light blooming (often called bloom for short) is a way of letting bright portions of the scene bleed into surrounding areas, serving to exaggerate the brightness of those areas. See Figure 8-11 for an example of light blooming.

Original image, crude bloom, and Gaussian bloom
Figure 8-11. Original image, crude bloom, and Gaussian bloom

For any postprocessing effect, the usual strategy is to render the scene into an FBO then draw a full-screen quad to the screen with the FBO attached to a texture. When drawing the full-screen quad, a special fragment shader is employed to achieve the effect.

I just described a single-pass process, but for many image-processing techniques (including bloom), a single pass is inefficient. To see why this is true, consider the halo around the white circle in the upper left of Figure 8-11; if the halo extends two or three pixels away from the boundary of the original circle, the pixel shader would need to sample the source texture over an area of 5×5 pixels, requiring a total of 25 texture lookups (see Figure 8-12). This would cause a huge performance hit.

5×5 filtering area
Figure 8-12. 5×5 filtering area

There are several tricks we can use to avoid a huge number of texture lookups. One trick is downsampling the original FBO into smaller textures. In fact, a simple (but crude) bloom effect can be achieved by filtering out the low-brightness regions, successively downsampling into smaller FBOs, and then accumulating the results. Example 8-13 illustrates this process using pseudocode.

Example 8-13. Algorithm for “crude bloom”
// 3D Rendering:
Set the render target to 320x480 FBO A.
Render 3D scene.

// High-Pass Filter:
Set the render target to 320x480 FBO B.
Bind FBO A as a texture.
Draw full-screen quad using a fragment shader that removes low-brightness regions.

// Downsample to one-half size:
Set the render target to 160x240 FBO C.
Bind FBO B as a texture.
Draw full-screen quad.

// Downsample to one-quarter size:
Set the render target to 80x120 FBO D.
Bind FBO C as a texture.
Draw full-screen quad.

// Accumulate the results:
Set the render target to the screen.
Bind FBO A as a texture.
Draw full-screen quad.
Enable additive blending.
Bind FBO B as a texture.
Draw full-screen quad.
Bind FBO C as a texture.
Draw full-screen quad.
Bind FBO D as a texture.
Draw full-screen quad.

This procedure is almost possible without the use of shaders; the main difficulty lies in the high-pass filter step. There are a couple ways around this; if you have a priori knowledge of the bright objects in your scene, simply render those objects directly into the FBO. Otherwise, you may be able to use texture combiners (covered at the beginning of this chapter) to subtract the low-brightness regions and then multiply the result back to its original intensity.

The main issue with the procedure outlined in Example 8-13 is that it’s using nothing more than OpenGL’s native facilities for bilinear filtering. OpenGL’s bilinear filter is also known as a box filter, aptly named since it produces rather boxy results, as shown in Figure 8-13.

Zoom on the original image, crude bloom, and Gaussian bloom
Figure 8-13. Zoom on the original image, crude bloom, and Gaussian bloom

A much higher-quality filter is the Gaussian filter, which gets its name from a function often used in statistics. It’s also known as the bell curve; see Figure 8-14.

Gaussian function
Figure 8-14. Gaussian function

Much like the box filter, the Gaussian filter samples the texture over the square region surrounding the point of interest. The difference lies in how the texel colors are averaged; the Gaussian filter uses a weighted average where the weights correspond to points along the bell curve.

The Gaussian filter has a property called separability, which means it can be split into two passes: a horizontal pass then a vertical one. So, for a 5×5 region of texels, we don’t need 25 lookups; instead, we can make five lookups in a horizontal pass then another five lookups in a vertical pass. The complete process is illustrated in Figure 8-15. The labels below each image tell you which framebuffer objects are being rendered to. Note that the B0–B3 set of FBOs are “ping-ponged” (yes, this term is used in graphics literature) to save memory, meaning that they’re rendered to more than once.

Gaussian bloom with 10 FBOs
Figure 8-15. Gaussian bloom with 10 FBOs

Yet another trick to reduce texture lookups is to sample somewhere other than at the texel centers. This exploits OpenGL’s bilinear filtering capabilities. See Figure 8-16 for an example of how five texture lookups can be reduced to three.

Five samples versus three samples
Figure 8-16. Five samples versus three samples

A bit of math proves that the five-lookup and three-lookup cases are equivalent if you use the correct off-center texture coordinates for the three-lookup case. First, give the row of texel colors names A through E, where C is the center of the filter. The weighted average from the five-lookup case can then be expressed as shown in Equation 8-1.

Equation 8-1. Weighted average over five texels
(A + 4*B + 6*C + 4*D + E) / 16 = A/16 + B*4/16 + C*6/16 + D*4/16 + E/16

For the three-lookup case, give the names F and G to the colors resulting from the off-center lookups. Equation 8-2 shows the weighted average.

Equation 8-2. Weighted average over three texels
(5*F + 6*C + 5*G) / 16 = F*5/16 + C*6/16 + G*5/16

The texture coordinate for F is chosen such that A contributes one-fifth of its color and B contributes four-fifths. The G coordinate follows the same scheme. This can be expressed like this:

Equation 8-3. 
F = (A + 4*B) / 5 = A/5 + B*4/5G = (E + 4*D) / 5 = E/5 + D*4/5

Substituting F and G in Equation 8-2 yields the following:

Equation 8-4. 
(A/5 + B*4/5)*5/16 + C*6/16 + (E/5 + D*4/5)*5/16

This is equivalent to Equation 8-1, which shows that three carefully chosen texture lookups can provide a good sample distribution over a 5-pixel area.

Better Performance with a Hybrid Approach

Full-blown Gaussian bloom may bog down your frame rate, even when using the sampling tricks that we discussed. In practice, I find that performing the blurring passes only on the smaller images provides big gains in performance with relatively little loss in quality.

Sample Code for Gaussian Bloom

Enough theory, let’s code this puppy. Example 8-14 shows the fragment shader used for high-pass filtering.

Example 8-14. High-pass filter fragment shader
varying mediump vec2 TextureCoord;

uniform sampler2D Sampler;
uniform mediump float Threshold;

const mediump vec3 Perception = vec3(0.299, 0.587, 0.114);

void main(void)
{
    mediump vec3 color = texture2D(Sampler, TextureCoord).xyz;
    mediump float luminance = dot(Perception, color);
    gl_FragColor = (luminance > Threshold) ? vec4(color, 1) : vec4(0);
}

Of interest in Example 8-14 is how we evaluate the perceived brightness of a given color. The human eye responds differently to different color components, so it’s not correct to simply take the “length” of the color vector.

Next let’s take a look at the fragment shader that’s used for Gaussian blur. Remember, it has only three lookups! See Example 8-15.

Example 8-15. Blur fragment shader
varying mediump vec2 TextureCoord;

uniform sampler2D Sampler;
uniform mediump float Coefficients[3];
uniform mediump vec2 Offset;

void main(void)
{
    mediump vec3 A = Coefficients[0] 
      * texture2D(Sampler, TextureCoord - Offset).xyz;
    mediump vec3 B = Coefficients[1] 
      * texture2D(Sampler, TextureCoord).xyz;
    mediump vec3 C = Coefficients[2] 
      * texture2D(Sampler, TextureCoord + Offset).xyz;
    mediump vec3 color = A + B + C;
    gl_FragColor = vec4(color, 1);
}

By having the application code supply Offset in the form of a vec2 uniform, we can use the same shader for both the horizontal and vertical passes. Speaking of application code, check out Example 8-16. The Optimize boolean turns on hybrid Gaussian/crude rendering; set it to false for a higher-quality blur at a reduced frame rate.

Example 8-16. Rendering engine (bloom sample)
const int OffscreenCount = 5;
const bool Optimize = true;
    
struct Framebuffers {
    GLuint Onscreen;
    GLuint Scene;
    GLuint OffscreenLeft[OffscreenCount];
    GLuint OffscreenRight[OffscreenCount];
};

struct Renderbuffers {
    GLuint Onscreen;
    GLuint OffscreenLeft[OffscreenCount];
    GLuint OffscreenRight[OffscreenCount];
    GLuint SceneColor;
    GLuint SceneDepth;
};

struct Textures {
    GLuint TombWindow;
    GLuint Sun;
    GLuint Scene;
    GLuint OffscreenLeft[OffscreenCount];
    GLuint OffscreenRight[OffscreenCount];
};

...

GLuint RenderingEngine::CreateFboTexture(int w, int h) const
{
    GLuint texture;
    glGenTextures(1, &texture);
    glBindTexture(GL_TEXTURE_2D, texture);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 
                 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);
    glFramebufferTexture2D(GL_FRAMEBUFFER, 
                           GL_COLOR_ATTACHMENT0, 
                           GL_TEXTURE_2D, 
                           texture, 
                           0);
    return texture;
}

void RenderingEngine::Initialize()
{
    // Load the textures:
    ...

    // Create some geometry:
    m_kleinBottle = CreateDrawable(KleinBottle(0.2), VertexFlagsNormals);
    m_quad = CreateDrawable(Quad(2, 2), VertexFlagsTexCoords);
    
    // Extract width and height from the color buffer:
    glGetRenderbufferParameteriv(GL_RENDERBUFFER, 
                                 GL_RENDERBUFFER_WIDTH, 
                                 &m_size.x);
    glGetRenderbufferParameteriv(GL_RENDERBUFFER, 
                                 GL_RENDERBUFFER_HEIGHT, 
                                 &m_size.y);
    
    // Create the onscreen FBO:
    glGenFramebuffers(1, &m_framebuffers.Onscreen);
    glBindFramebuffer(GL_FRAMEBUFFER, m_framebuffers.Onscreen);
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                              GL_RENDERBUFFER, m_renderbuffers.Onscreen);
    glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffers.Onscreen);

    // Create the depth buffer for the full-size offscreen FBO:
    glGenRenderbuffers(1, &m_renderbuffers.SceneDepth);
    glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffers.SceneDepth);
    glRenderbufferStorage(GL_RENDERBUFFER, 
                          GL_DEPTH_COMPONENT16, 
                          m_size.x, 
                          m_size.y);
    glGenRenderbuffers(1, &m_renderbuffers.SceneColor);
    glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffers.SceneColor);
    glRenderbufferStorage(GL_RENDERBUFFER, 
                          GL_RGBA8_OES, 
                          m_size.x, 
                          m_size.y);
    glGenFramebuffers(1, &m_framebuffers.Scene);
    glBindFramebuffer(GL_FRAMEBUFFER, m_framebuffers.Scene);
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                              GL_RENDERBUFFER, m_renderbuffers.SceneColor);
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
                              GL_RENDERBUFFER, m_renderbuffers.SceneDepth);
    m_textures.Scene = CreateFboTexture(m_size.x, m_size.y);
    
    // Create FBOs for the half, quarter, and eighth sizes:
    int w = m_size.x, h = m_size.y;
    for (int i = 0; 
         i < OffscreenCount; 
         ++i, w >>= 1, h >>= 1) 
    {
        glGenRenderbuffers(1, &m_renderbuffers.OffscreenLeft[i]);
        glBindRenderbuffer(GL_RENDERBUFFER, 
                           m_renderbuffers.OffscreenLeft[i]);
        glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8_OES, w, h);
        glGenFramebuffers(1, &m_framebuffers.OffscreenLeft[i]);
        glBindFramebuffer(GL_FRAMEBUFFER, 
                          m_framebuffers.OffscreenLeft[i]);
        glFramebufferRenderbuffer(GL_FRAMEBUFFER, 
                                  GL_COLOR_ATTACHMENT0,
                                  GL_RENDERBUFFER, 
                                  m_renderbuffers.OffscreenLeft[i]);
        m_textures.OffscreenLeft[i] = CreateFboTexture(w, h);

        glGenRenderbuffers(1, &m_renderbuffers.OffscreenRight[i]);
        glBindRenderbuffer(GL_RENDERBUFFER, 
                           m_renderbuffers.OffscreenRight[i]);
        glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8_OES, w, h);
        glGenFramebuffers(1, &m_framebuffers.OffscreenRight[i]);
        glBindFramebuffer(GL_FRAMEBUFFER, 
                          m_framebuffers.OffscreenRight[i]);
        glFramebufferRenderbuffer(GL_FRAMEBUFFER, 
                                  GL_COLOR_ATTACHMENT0, 
                                  GL_RENDERBUFFER, 
                                  m_renderbuffers.OffscreenRight[i]);
        m_textures.OffscreenRight[i] = CreateFboTexture(w, h);
    }
    
    ...
}

void RenderingEngine::Render(float theta) const
{
    glViewport(0, 0, m_size.x, m_size.y);
    glEnable(GL_DEPTH_TEST);

    // Set the render target to the full-size offscreen buffer:
    glBindTexture(GL_TEXTURE_2D, m_textures.TombWindow);
    glBindFramebuffer(GL_FRAMEBUFFER, m_framebuffers.Scene);
    glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffers.SceneColor);

    // Blit the background texture:
    glUseProgram(m_blitting.Program);
    glUniform1f(m_blitting.Uniforms.Threshold, 0);
    glDepthFunc(GL_ALWAYS);
    RenderDrawable(m_quad, m_blitting);

    // Draw the sun:
    ...

    // Set the light position:
    glUseProgram(m_lighting.Program);
    vec4 lightPosition(0.25, 0.25, 1, 0);
    glUniform3fv(m_lighting.Uniforms.LightPosition, 1, 
                 lightPosition.Pointer());

    // Set the model-view transform:
    const float distance = 10;
    const vec3 target(0, -0.15, 0);
    const vec3 up(0, 1, 0);
    const vec3 eye = vec3(0, 0, distance);
    const mat4 view = mat4::LookAt(eye, target, up);
    const mat4 model = mat4::RotateY(theta * 180.0f / 3.14f);
    const mat4 modelview = model * view;
    glUniformMatrix4fv(m_lighting.Uniforms.Modelview, 
                       1, 0, modelview.Pointer());

    // Set the normal matrix:
    mat3 normalMatrix = modelview.ToMat3();
    glUniformMatrix3fv(m_lighting.Uniforms.NormalMatrix, 
                       1, 0, normalMatrix.Pointer());

    // Render the Klein bottle:
    glDepthFunc(GL_LESS);
    glEnableVertexAttribArray(m_lighting.Attributes.Normal);
    RenderDrawable(m_kleinBottle, m_lighting);
    
    // Set up the high-pass filter:
    glUseProgram(m_highPass.Program);
    glUniform1f(m_highPass.Uniforms.Threshold, 0.85);
    glDisable(GL_DEPTH_TEST);

    // Downsample the rendered scene:
    int w = m_size.x, h = m_size.y;
    for (int i = 0; i < OffscreenCount; ++i, w >>= 1, h >>= 1) {
        glViewport(0, 0, w, h);
        glBindFramebuffer(GL_FRAMEBUFFER, m_framebuffers.OffscreenLeft[i]);
        glBindRenderbuffer(GL_RENDERBUFFER, 
                           m_renderbuffers.OffscreenLeft[i]);
        glBindTexture(GL_TEXTURE_2D, i ? m_textures.OffscreenLeft[i - 1] :
                                         m_textures.Scene);
        RenderDrawable(m_quad, m_blitting);
        glUseProgram(m_blitting.Program);
    }
    
    // Set up for Gaussian blur:
    float kernel[3] = { 5.0f / 16.0f, 6 / 16.0f, 5 / 16.0f };
    glUseProgram(m_gaussian.Program);
    glUniform1fv(m_gaussian.Uniforms.Coefficients, 3, kernel);

    // Perform the horizontal blurring pass:
    w = m_size.x; h = m_size.y;
    for (int i = 0; i < OffscreenCount; ++i, w >>= 1, h >>= 1) {
        if (Optimize && i < 2)
            continue;
        float offset = 1.2f / (float) w;
        glUniform2f(m_gaussian.Uniforms.Offset, offset, 0);
        glViewport(0, 0, w, h);
        glBindFramebuffer(GL_FRAMEBUFFER, 
                          m_framebuffers.OffscreenRight[i]);
        glBindRenderbuffer(GL_RENDERBUFFER, 
                           m_renderbuffers.OffscreenRight[i]);
        glBindTexture(GL_TEXTURE_2D, m_textures.OffscreenLeft[i]);
        RenderDrawable(m_quad, m_gaussian);
    }

    // Perform the vertical blurring pass:
    w = m_size.x; h = m_size.y;
    for (int i = 0; i < OffscreenCount; ++i, w >>= 1, h >>= 1) {
        if (Optimize && i < 2)
            continue;
        float offset = 1.2f / (float) h;
        glUniform2f(m_gaussian.Uniforms.Offset, 0, offset);
        glViewport(0, 0, w, h);
        glBindFramebuffer(GL_FRAMEBUFFER, m_framebuffers.OffscreenLeft[i]);
        glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffers.OffscreenLeft[i]);
        glBindTexture(GL_TEXTURE_2D, m_textures.OffscreenRight[i]);
        RenderDrawable(m_quad, m_gaussian);
    }

    // Blit the full-color buffer onto the screen:
    glUseProgram(m_blitting.Program);
    glViewport(0, 0, m_size.x, m_size.y);
    glDisable(GL_BLEND);
    glBindFramebuffer(GL_FRAMEBUFFER, m_framebuffers.Onscreen);
    glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffers.Onscreen);
    glBindTexture(GL_TEXTURE_2D, m_textures.Scene);
    RenderDrawable(m_quad, m_blitting);

    // Accumulate the bloom textures onto the screen:
    glBlendFunc(GL_ONE, GL_ONE);
    glEnable(GL_BLEND);
    for (int i = 1; i < OffscreenCount; ++i) {
        glBindTexture(GL_TEXTURE_2D, m_textures.OffscreenLeft[i]);
        RenderDrawable(m_quad, m_blitting);
    }
    glDisable(GL_BLEND);
}

In Example 8-16, some utility methods and structures are omitted for brevity, since they’re similar to what’s found in previous samples. As always, you can download the entire source code for this sample from this book’s website.

Keep in mind that bloom is only one type of image-processing technique; there are many more techniques that you can achieve with shaders. For example, by skipping the high-pass filter, you can soften the entire image; this could be used as a poor man’s anti-aliasing technique.

Also note that image-processing techniques are often applicable outside the world of 3D graphics—you could even use OpenGL to perform a bloom pass on an image captured with the iPhone camera!

Wrapping Up

This chapter picked up the pace a bit, giving a quick overview of some more advanced concepts. I hope you feel encouraged to do some additional reading; computer graphics is a deep field, and there’s plenty to learn that’s outside the scope of this book.

Many of the effects presented in this chapter are possible only at the cost of a lower frame rate. You’ll often come across a trade-off between visual quality and performance, but there are tricks to help with this. In the next chapter, we’ll discuss some of these optimization tricks and give you a leg up in your application’s performance.

Get iPhone 3D Programming now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.