Chapter 4. Adding Depth and Realism
Lumos!
When my wife and I go out to see a film packed with special effects, I always insist on sitting through the entire end credits, much to her annoyance. It never ceases to amaze me how many artists work together to produce a Hollywood blockbuster. I'm often impressed with the number of artists whose full-time job concerns lighting. In Pixar's Up, at least five people have the title "lighting technical director", four people have the title "key lighting artist", and another four people have the honor of "master lighting artist".
Lighting is obviously a key aspect to understanding realism in computer graphics, and that's much of what this chapter is all about. We'll refurbish the Wireframe Viewer sample to use lighting and triangles, rechristening it to "Model Viewer". We'll also throw some light on the subject of shaders, which we've been glossing over till now (in ES 2.0, shaders are critical to lighting). Finally, we'll further enhance the viewer app by giving it the ability to load model files, so that we're not stuck with parametric surfaces forever. Mathematical shapes are great for geeking out, but they're pretty lame for impressing your ten year-old!
Examining the Depth Buffer
Before diving into lighting, let's take a closer look at depth buffers, since we'll need to add one to Wireframe Viewer. You might recall the funky frame buffer object (FBO) set-up code in the Hello Cone sample presented in Example 2.7, “RenderingEngine Initialization”, repeated here in Example 4.1, “Depth Buffer Setup”.
Example 4.1. Depth Buffer Setup
// Create the depth buffer. glGenRenderbuffersOES(1, &m_depthRenderbuffer);glBindRenderbufferOES(GL_RENDERBUFFER_OES, m_depthRenderbuffer);
glRenderbufferStorageOES(GL_RENDERBUFFER_OES,
GL_DEPTH_COMPONENT16_OES, width, height); // Create the framebuffer object; attach the depth and color buffers. glGenFramebuffersOES(1, &m_framebuffer); glBindFramebufferOES(GL_FRAMEBUFFER_OES, m_framebuffer); glFramebufferRenderbufferOES(GL_FRAMEBUFFER_OES, GL_COLOR_ATTACHMENT0_OES, GL_RENDERBUFFER_OES, m_colorRenderbuffer); glFramebufferRenderbufferOES(GL_FRAMEBUFFER_OES,
GL_DEPTH_ATTACHMENT_OES, GL_RENDERBUFFER_OES, m_depthRenderbuffer); // Bind the color buffer for rendering. glBindRenderbufferOES(GL_RENDERBUFFER_OES, m_colorRenderbuffer); glViewport(0, 0, width, height); glEnable(GL_DEPTH_TEST);
...
Create a handle to the renderbuffer object that stores depth. | |
Bind the newly created handle, making it affected by subsequent renderbuffer commands. | |
Allocate storage for the depth buffer using 16-bit precision. | |
Attach the depth buffer to the framebuffer object. | |
Enable depth testing — we'll explain this shortly. |
Why does Hello Cone need a depth buffer when Wireframe Viewer does not? When the scene is composed of nothing but monochrome lines, we don't care about the visibility problem; this means we don't care which lines are obscured by other lines. Hello Cone uses triangles rather than lines, so the visibility problem needs to be addressed. OpenGL uses the depth buffer to handle this problem efficiently.
Figure 4.1, “Depth Buffer in Model Viewer” is a depiction of Model Viewer's depth buffer in grayscale: white pixels are far away, black pixels are nearby. Even though users can't see the depth buffer, OpenGL needs it for its rendering algorithm. If it didn't have a depth buffer, you'd be forced to carefully order your draw calls from furthest to nearest. (Incidentally, such an ordering is called the painter's algorithm, and there are special cases where you'll need to use it anyway, as you'll see in a future chapter that covers blending.)
OpenGL uses a technique called depth testing to solve the visibility problem. Suppose you were to render a red triangle directly in front of the camera, then draw a green triangle directly behind the red triangle. Even though the green triangle is drawn last, you'd want to the red triangle to be visible; the green triangle is said to be occluded. Here's how it works: every rasterized pixel not only has its RGB values written to the color buffer, but also has its Z value written to the depth buffer. OpenGL "rejects" occluded pixels by checking if their Z value is greater than the Z value that's already in the depth buffer. In pseudocode, the algorithm looks like this:
void WritePixel(x, y, z, color)
{
if (DepthTestDisabled || z < DepthBuffer[x, y]) {
DepthBuffer[x, y] = z;
ColorBuffer[x, y] = color;
}
}- Push out your near plane.
For perspective projections, having the near plane close to zero can be detrimental to precision.
- Pull in your far plane.
Similarly, the far plane should still be pulled in as far as possible without clipping away portions of your scene.
- Scale your scene smaller.
Try to avoid defining an astronomical-scale scene with huge extents.
- Increase the bit width of your depth buffer.
All iPhones and iPod touches (at the time of this writing) support 16-bit and 24-bit depth formats. The bit width is determined according to the argument you pass to
glRenderbufferStorageOESwhen allocating the depth buffer.- Are you accidentally rendering coplanar triangles?
The fault might not lie with OpenGL but with your application code. Perhaps your generated vertices are lying on the same Z plane due to a rounding error.
- Do you really need depth testing in the first place?
In some cases you should probably disable depth testing anyway. For example, you don't need it if you're rendering a two-dimensional heads-up-display. Disabling the depth test can also boost performance.
Beware the Scourge of Depth Artifacts
Something to watch out for with depth buffers is Z-fighting, which is a visual artifact that occurs when overlapping triangles have depths that are too close to each other (see Figure 4.2, “Z Fighting in the Möbius Strip”).
Recall that the projection matrix defines a viewing frustum
bounded by six planes (the section called “Setting the Projection Transform”). The two
planes that are perpendicular to the viewing direction are called the
near plane and far plane. In ES 1.1, these planes
are arguments to the glOrtho or
glPerspective functions; in ES 2.0, they're passed to
a custom function like the mat4::Frustum method in
the C++ vector library from Appendix A, C++ Vector Library.
It turns out that if the near plane is too close to the camera, or if the far plane is too distant, this can cause precision issues that result in Z-fighting. However this is only one possible cause for Z-fighting; there are many more. Take a look at the following list of suggestions if you ever see artifacts like the ones in Figure 4.2, “Z Fighting in the Möbius Strip”.
Creating and Using the Depth Buffer
Let's enhance the Wireframe Viewer app by adding in a depth buffer; this paves the way for converting the wireframes into solid triangles. Before making any changes, use Finder to make a copy of the folder that contains the SimpleWireframe project. Rename the folder to ModelViewer, and then open the copy of the SimpleWireframe project inside that folder. Select Project→Rename and rename the project to ModelViewer.
Open RenderingEngine.ES1.cpp, and add
GLuint m_depthRenderbuffer; to the
private: section of the class declaration. Next, find
the Initialize method, and delete everything from the
comment // Create the framebuffer object to the
glBindRenderbufferOES call. Replace the code you
deleted with the code in Example 4.2, “Adding Depth to ES1::RenderingEngine::Initialize”.
Example 4.2. Adding Depth to ES1::RenderingEngine::Initialize
// Extract width and height from the color buffer.
int width, height;
glGetRenderbufferParameterivOES(GL_RENDERBUFFER_OES,
GL_RENDERBUFFER_WIDTH_OES, &width);
glGetRenderbufferParameterivOES(GL_RENDERBUFFER_OES,
GL_RENDERBUFFER_HEIGHT_OES, &height);
// Create a depth buffer that has the same size as the color buffer.
glGenRenderbuffersOES(1, &m_depthRenderbuffer);
glBindRenderbufferOES(GL_RENDERBUFFER_OES, m_depthRenderbuffer);
glRenderbufferStorageOES(GL_RENDERBUFFER_OES, GL_DEPTH_COMPONENT16_OES,
width, height);
// Create the framebuffer object.
GLuint framebuffer;
glGenFramebuffersOES(1, &framebuffer);
glBindFramebufferOES(GL_FRAMEBUFFER_OES, framebuffer);
glFramebufferRenderbufferOES(GL_FRAMEBUFFER_OES, GL_COLOR_ATTACHMENT0_OES,
GL_RENDERBUFFER_OES, m_colorRenderbuffer);
glFramebufferRenderbufferOES(GL_FRAMEBUFFER_OES, GL_DEPTH_ATTACHMENT_OES,
GL_RENDERBUFFER_OES, m_depthRenderbuffer);
glBindRenderbufferOES(GL_RENDERBUFFER_OES, m_colorRenderbuffer);
// Enable depth testing.
glEnable(GL_DEPTH_TEST);
The ES 2.0 variant of Example 4.2, “Adding Depth to ES1::RenderingEngine::Initialize” is almost exactly the same. Repeat the process in
that file, but remove all _OES and
OES suffixes.
Next, find the call to glClear (in both rendering
engines) and add a flag for depth:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);At this point, you should be able to compile and run, although depth-testing doesn't buy you anything yet since the app is still rendering in wireframe.
By default, the depth buffer gets cleared to a
value of 1.0; this makes sense since you want all your pixels to initially
pass the depth test, and OpenGL clamps the maximum window-space Z
coordinate to 1.0. Incidentally, if you want to clear the depth buffer to
some other value, you can call glClearDepthf, similar
to glClearColor. You can even configure the depth test
itself using glDepthFunc. By default, pixels "win" if
their Z is less than the value in the depth buffer,
but you can change the test to any of these conditions:
- GL_NEVER
Pixels never pass the depth test.
- GL_ALWAYS
Pixels always pass the depth test.
- GL_LESS
Pixels pass only if their Z value is less than the Z value in the depth buffer. This is the default.
- GL_LEQUAL
Pixels pass only if their Z value is less than or equal to the Z value in the depth buffer.
- GL_EQUAL
Pixels pass only if their Z value is equal to the Z value in the depth buffer. This could be used to create an infinitely thin slice of the scene.
- GL_GREATER
Pixels pass only if their Z value is greater than the Z value in the depth buffer.
- GL_GEQUAL
Pixels pass only if their Z value is greater than or equal to the Z value in the depth buffer.
- GL_NOTEQUAL
Pixels pass only if their Z value is not equal to the Z value in the depth buffer.
The flexibility of
glDepthFunc is a shining example of how OpenGL is often
configurable to an extent more than you really need. I personally admire
this type of design philosophy in an API; anything that is reasonably easy
to implement in hardware is exposed to the developer at a low level. This
makes the API forward-looking because it enables developers to dream up
unusual effects that the API designers did not necessarily
anticipate.
Filling the Wireframe with Triangles
In this section we'll walk through the steps
required to render parametric surfaces with triangles rather than lines.
First we need to enhance the ISurface interface to
support generation of indices for triangles rather than lines. Open
Interfaces.hpp and make the changes shown in boldface
in Example 4.3, “Enhanced ISurface Interface”.
Example 4.3. Enhanced ISurface Interface
struct ISurface {
virtual int GetVertexCount() const = 0;
virtual int GetLineIndexCount() const = 0;
virtual int GetTriangleIndexCount() const = 0;
virtual void GenerateVertices(vector<float>& vertices) const = 0;
virtual void GenerateLineIndices(vector<unsigned short>& indices) const = 0;
virtual void
GenerateTriangleIndices(vector<unsigned short>& indices) const = 0;
virtual ~ISurface() {}
};
You'll also need to open
ParametricSurface.hpp and make the complementary
changes to the class declaration of ParametricSurface
shown in Example 4.4, “Enhanced ParametricSurface Interface”.
Example 4.4. Enhanced ParametricSurface Interface
class ParametricSurface : public ISurface {
public:
int GetVertexCount() const;
int GetLineIndexCount() const;
int GetTriangleIndexCount() const;
void GenerateVertices(vector<float>& vertices) const;
void GenerateLineIndices(vector<unsigned short>& indices) const;
void GenerateTriangleIndices(vector<unsigned short>& indices) const;Next open ParametericSurface.cpp and add the
implementation of GetTriangleIndexCount and
GenerateTriangleIndices per Example 4.5, “ParametricSurface::GenerateTriangleIndices”.
Example 4.5. ParametricSurface::GenerateTriangleIndices
int ParametricSurface::GetTriangleIndexCount() const
{
return 6 * m_slices.x * m_slices.y;
}
void
ParametricSurface::GenerateTriangleIndices(vector<unsigned short>& indices) const
{
indices.resize(GetTriangleIndexCount());
vector<unsigned short>::iterator index = indices.begin();
for (int j = 0, vertex = 0; j < m_slices.y; j++) {
for (int i = 0; i < m_slices.x; i++) {
int next = (i + 1) % m_divisions.x;
*index++ = vertex + i;
*index++ = vertex + next;
*index++ = vertex + i + m_divisions.x;
*index++ = vertex + next;
*index++ = vertex + next + m_divisions.x;
*index++ = vertex + i + m_divisions.x;
}
vertex += m_divisions.x;
}
}
Example 4.5, “ParametricSurface::GenerateTriangleIndices” is computing indices for two triangles, as shown in Figure 4.3, “Generating Triangle Indices for a Parametric Surface”.
Now we need to modify the rendering engine so
that it calls these new methods when generating VBOs, as in Example 4.6, “RenderingEngine Modifications for Triangles”. The modified lines are shown in bold.
Make these changes to both RenderingEngine.ES1.cpp
and RenderingEngine.ES2.cpp.
Example 4.6. RenderingEngine Modifications for Triangles
void RenderingEngine::Initialize(const vector<ISurface*>& surfaces)
{
vector<ISurface*>::const_iterator surface;
for (surface = surfaces.begin(); surface != surfaces.end(); ++surface) {
// Create the VBO for the vertices.
vector<float> vertices;
(*surface)->GenerateVertices(vertices);
GLuint vertexBuffer;
glGenBuffers(1, &vertexBuffer);
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
glBufferData(GL_ARRAY_BUFFER,
vertices.size() * sizeof(vertices[0]),
&vertices[0],
GL_STATIC_DRAW);
// Create a new VBO for the indices if needed.
int indexCount = (*surface)->GetTriangleIndexCount();
GLuint indexBuffer;
if (!m_drawables.empty() && indexCount == m_drawables[0].IndexCount) {
indexBuffer = m_drawables[0].IndexBuffer;
} else {
vector<GLushort> indices(indexCount);
(*surface)->GenerateTriangleIndices(indices);
glGenBuffers(1, &indexBuffer);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER,
indexCount * sizeof(GLushort),
&indices[0],
GL_STATIC_DRAW);
}
Drawable drawable = { vertexBuffer, indexBuffer, indexCount};
m_drawables.push_back(drawable);
}
...
}
void RenderingEngine::Render(const vector<Visual>& visuals) const
{
glClearColor(0.5, 0.5f, 0.5f, 1);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
vector<Visual>::const_iterator visual = visuals.begin();
for (int visualIndex = 0;
visual != visuals.end();
++visual, ++visualIndex)
{
//...
// Draw the surface.
int stride = sizeof(vec3);
const Drawable& drawable = m_drawables[visualIndex];
glBindBuffer(GL_ARRAY_BUFFER, drawable.VertexBuffer);
glVertexPointer(3, GL_FLOAT, stride, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, drawable.IndexBuffer);
glDrawElements(GL_TRIANGLES, drawable.IndexCount, GL_UNSIGNED_SHORT, 0);
}
}
Getting back to the sample app, at this point Wireframe Viewer has officially become Model Viewer; feel free to build it and try it out. You may be disappointed — the result is horribly boring, as seen in Figure 4.4, “Model Viewer without Lighting”. Lighting to the rescue!
Surface Normals
Before we can enable lighting, there's yet another prerequisite we need to get out of the way. To perform the math for lighting, OpenGL must be provided with a surface normal at every vertex. A surface normal (often simply called a normal) is simply a vector perpendicular to the surface; it effectively defines the orientation of a small piece of the surface.
Feeding OpenGL with Normals
You might recall that normals are one of the pre-defined vertex attributes in OpenGL ES 1.1. They can be enabled like this:
// OpenGL ES 1.1 glEnableClientState(GL_NORMAL_ARRAY); glNormalPointer(GL_FLOAT, stride, offset); glEnable(GL_NORMALIZE); // OpenGL ES 2.0 glEnableVertexAttribArray(myNormalSlot); glVertexAttribPointer(myNormalSlot, 3, GL_FLOAT, normalize, stride, offset);
I snuck in something new in the above
snippet: the GL_NORMALIZE state in ES 1.1, and the
normalize argument in ES 2.0. Both are used to
control whether or not OpenGL processes your normal vectors to make them
unit length. If you already know that your normals are unit length, do
not turn this feature on; it incurs a performance hit.
Warning
Don't confuse normalize, which refers to making any vector into a unit vector, and normal vector, which refers to any vector that is perpendicular to a surface. It is not redundant to say "normalized normal".
Even though OpenGL ES 1.1 can perform much of the lighting math on your behalf, it does not compute surface normals for you. At first this may seem rather ungracious on OpenGL's part, but as you'll see later, stipulating the normals yourself give you the power to render interesting effects. While the mathematical notion of a normal is well-defined, the OpenGL notion of a normal is simply another input with discretionary values, much like color and position. Mathematicians live in an ideal world of smooth surfaces, but graphics programmers live in a world of triangles. If you were to make the normals in every triangle point in the exact direction that the triangle is facing, your model would looked faceted and artificial; every triangle would have a uniform color. By supplying normals yourself, you can make your model seem smooth, faceted, or even bumpy, as we'll see later.
The Math Behind Normals
We scoff at mathematicians for living in an artificially ideal world, but we can't dismiss the math behind normals; we need it to come up with sensible values in the first place. Central to the mathematical notion of a normal is the concept of a tangent plane, depicted in Figure 4.5, “Normal Vector with Tangent Plane”.
The diagram in Figure 4.5, “Normal Vector with Tangent Plane” is, in itself, perhaps the best definition of the tangent plane that I can give you without going into calculus. It's the plane that "just touches" your surface at a given point P. Think like a mathematician: for them, a plane is minimally defined with three points. So, imagine three points at random positions on your surface, then create a plane that contains them all. Slowly move the three points towards each other; just before the three points converge, the plane they define is the tangent plane.
The tangent plane can also be defined with
tangent and binormal vectors (u and
v in Figure 4.5, “Normal Vector with Tangent Plane”), which are easiest to define within the
context of a parametric surface. Each of these correspond to a dimension
of the domain; we'll make use of this when we add normals to our
ParametricSurface class.
Finding two vectors in the tangent plane is usually fairly easy. For example, you can take any two sides of a triangle; the two vectors need not be at right angles to each other. Simply take their cross product and unitize the result. For parametric surfaces, the procedure can be summarized with the following pseudocode:
p = Evaluate(s, t) u = Evaluate(s + ds, t) - p v = Evaluate(s, t + dt) - p N = Normalize(u × v)
Don't be frightened by the cross product, I'll give you a brief refresher. The cross product always generates a vector perpendicular to its two input vectors. You can visualize the cross product of A with B using your right hand. Point your index finger in the direction of A, then point your middle finger towards B; your thumb now points in the direction of A×B (pronounced "A cross B", not "A times B"). See Figure 4.6, “Right-Hand Rule”.
Here's the relevant snippet from our C++ library (see Appendix A, C++ Vector Library for a full listing):
template <typename T>
struct Vector3 {
// ...
Vector3 Cross(const Vector3& v) const
{
return Vector3(y * v.z - z * v.y,
z * v.x - x * v.z,
x * v.y - y * v.x);
}
// ...
T x, y, z;
};
Normal Transforms Aren't Normal
Let's not lose focus on why we're generating normals in the first place: they're required for the lighting algorithms that we cover later in this chapter. Recall from Chapter 2, Math and Metaphors that vertex position can live in different spaces: object space, world space, and so on. Normal vectors can live in these different spaces too; it turns out that lighting in the vertex shader is often performed in eye space. (There are certain conditions in which it can be done in object space, but that's a discussion for another day.)
So, we need to transform our normals to eye space. Since vertex positions get transformed by the model-view matrix to bring them into eye space, it follows that normal vectors get transformed the same way, right? Wrong! Actually, wrong sometimes. This is one of the tricker concepts in graphics to understand, so bear with me.
Look at the heart shape in Figure 4.7, “Normal Transforms” and consider the surface normal at a point in the upper-left quadrant (depicted with an arrow). The figure on the far left is the original shape and the middle figure shows what happens after we translate, rotate, and uniformly shrink the heart. The transformation for the normal vector is almost the same as the model's transformation; the only difference is that it's a vector and therefore doesn't require translation. Removing translation from a 4x4 transformation matrix is easy. Simply extract the upper-left 3x3 matrix and you're done.
Now take a look at the figure on the far right, which shows what happens when stretching the model only along its x-axis. In this case, if we were to apply the upper 3x3 of the model-view matrix to the normal vector, we'd get an incorrect result; the normal would no longer be perpendicular to the surface. This shows that simply extracting the upper-left 3x3 matrix from the model-view matrix doesn't always suffice. I won't bore you with the math, but it can be shown that the correct transform for normal vectors is actually the inverse-transpose of the model-view matrix, which is the result of two operations: first an inverse, then a transpose.
The inverse matrix of
M is denoted M-1; it's the matrix that
results in the identity matrix when multiplied with the original matrix.
Inverse matrices are somewhat non-trivial to compute, so again I'll
refrain from boring you with the math. The
transpose matrix, on the other hand, is easy to
derive; simply swap the rows and columns of the matrix such that
M[i][j] becomes M[j][i].
Transposes are denoted MT, so the proper transform for normal vectors looks like this:
Don't forget the middle shape in Figure 4.7, “Normal Transforms”; it shows that, at least in some cases, the upper 3x3 of the original model-view matrix can be used to transform the normal vector. In this case, the matrix just happens to be equal to its own inverse-transpose; such matrices are called orthogonal. Rigid body transformations like rotation and uniform scale always result in orthogonal matrices.
Why did I bore you with all this mumbo jumbo about inverses and normal transforms? Two reasons. In ES 1.1, keeping non-uniform scale out of your matrix helps performance because OpenGL can avoid computing the inverse-transpose of the model-view. Secondly, for ES 2.0, you need to understand nitty-gritty details like this anyway to write sensible lighting shaders!
Generating Normals from Parametric Surfaces
Enough academic babble; let's get back to
coding. Since our goal here is to add lighting to Model Viewer, we need
to implement the generation of normal vectors. Let's tweak
ISurface in Interfaces.hpp by
adding a flags parameter to GenerateVertices, as seen
in Example 4.7, “Modifying ISurface with Support for Normals”. New or modified lines are shown in
bold.
Example 4.7. Modifying ISurface with Support for Normals
enum VertexFlags { VertexFlagsNormals = 1 << 0, VertexFlagsTexCoords = 1 << 1, }; struct ISurface { virtual int GetVertexCount() const = 0; virtual int GetLineIndexCount() const = 0; virtual int GetTriangleIndexCount() const = 0; virtual void GenerateVertices(vector<float>& vertices, unsigned char flags = 0) const = 0; virtual void GenerateLineIndices(vector<unsigned short>& indices) const = 0; virtual void GenerateTriangleIndices(vector<unsigned short>& indices) const = 0; virtual ~ISurface() {} };
The argument we added to
GenerateVertices could have been a boolean instead of
a bit mask, but we'll eventually want to feed additional vertex
attributes to OpenGL, such as texture coordinates. For now, just ignore
the VertexFlagsTexCoords flag; it'll come in handy in
the next chapter.
Next we need to open
ParametricSurface.hpp and make the complementary
change to the class declaration of ParametricSurface,
as seen in Example 4.8, “ParametricSurface Class Declaration”. We'll also add
a new protected method called InvertNormal, which
derived classes can optionally override.
Example 4.8. ParametricSurface Class Declaration
class ParametricSurface : public ISurface {
public:
int GetVertexCount() const;
int GetLineIndexCount() const;
int GetTriangleIndexCount() const;
void GenerateVertices(vector<float>& vertices, unsigned char flags) const;
void GenerateLineIndices(vector<unsigned short>& indices) const;
void GenerateTriangleIndices(vector<unsigned short>& indices) const;
protected:
void SetInterval(const ParametricInterval& interval);
virtual vec3 Evaluate(const vec2& domain) const = 0;
virtual bool InvertNormal(const vec2& domain) const { return false; }
private:
vec2 ComputeDomain(float i, float j) const;
vec2 m_upperBound;
ivec2 m_slices;
ivec2 m_divisions;
};Next let's open
ParametericSurface.cpp and replace the
implementation of GenerateVertices, as seen in Example 4.9, “Adding Normals to ParametricSurface::GenerateVertices”.
Example 4.9. Adding Normals to ParametricSurface::GenerateVertices
void ParametricSurface::GenerateVertices(vector<float>& vertices,
unsigned char flags) const
{
int floatsPerVertex = 3;
if (flags & VertexFlagsNormals)
floatsPerVertex += 3;
vertices.resize(GetVertexCount() * floatsPerVertex);
float* attribute = (float*) &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) {
float s = i, t = j;
// Nudge the point if the normal is indeterminate.
if (i == 0) s += 0.01f;
if (i == m_divisions.x - 1) s -= 0.01f;
if (j == 0) t += 0.01f;
if (j == m_divisions.y - 1) t -= 0.01f;
// Compute the tangents and their cross product.
vec3 p = Evaluate(ComputeDomain(s, t));
vec3 u = Evaluate(ComputeDomain(s + 0.01f, t)) - p;
vec3 v = Evaluate(ComputeDomain(s, t + 0.01f)) - p;
vec3 normal = u.Cross(v).Normalized();
if (InvertNormal(domain))
normal = -normal;
attribute = normal.Write(attribute);
}
}
}
}
Compute the position of the vertex by
calling | |
Copy the | |
Surfaces might be non-smooth in some places where the normal is impossible to determine (for example, at the apex of the cone). So, we have a bit of a hack here, which is to nudge the point of interest in the problem areas. | |
As covered in the section called “Feeding OpenGL with Normals”, compute the two tangent vectors and take their cross product. | |
Subclasses are allowed to invert the normal if they wish. (If the normal points away from the light source, then it's considered to be the back of the surface and therefore looks dark.) The only shape that overrides this method is the Klein Bottle. | |
Copy the normal vector into the data
buffer using its |
This completes the changes to
ParametricSurface. You should be able to build Model
Viewer at this point, but it will look the same since we have yet to put
the normal vectors to good use. That comes next.
Lighting Up
Drawing is deception.
The foundations of real-time graphics are almost never based on principles from physics and optics. In a way, the lighting equations we'll cover in this section are cheap hacks, simple models based on rather shallow empirical observations. We'll be demonstrating three different lighting models: ambient lighting (subtle, monotone light), diffuse lighting (the dull matte component of reflection), and specular lighting (the shiny spot on a fresh red apple). Figure 4.8, “Ambient + Diffuse + Specular = Final” shows how these three lighting models can be combined to produce a high-quality image.
Of course, in the real world, there are no such things as "diffuse photons" and "specular photons". Don't be disheartened by this pack of lies! Computer graphics is always just a great big hack at some level, and knowing this will make you stronger. Even the fact that colors are ultimately represented by a red-green-blue triplet has more to do with human perception than with optics. The reason we use RGB? It happens to match the three types of color sensing cells in the human retina! A good graphics programmer can think like a politician and use lies to his advantage.
Ho-Hum Ambiance
Realistic ambient lighting, with the soft, muted shadows that it conjures up, can be very complex to render (you can see an example of ambient occlusion in the section called “Baked Lighting”), but ambient lighting in the context of OpenGL usually refers to something far more trivial: a solid, uniform color. Calling this "lighting" is questionable since its intensity is not impacted by the position of the light source or the orientation of the surface, but it is often combined with the other lighting models to produce a brighter surface.
Matte Paint With Diffuse Lighting
The most common form of real-time lighting is diffuse lighting, which varies its brightness according to the angle between the surface and the light source. Also known as lambertian reflection, this form of lighting is predominant because it's simple to compute, and it adequately conveys depth to the human eye. Figure 4.9, “Diffuse Lighting” shows how diffuse lighting works. In the diagram, L is the unit length vector pointing to the light source, and N is the surface normal, which is a unit-length vector that's perpendicular to the surface. We'll learn how to compute N later in the chapter.
The diffuse factor
(known as df in Figure 4.9, “Diffuse Lighting”) lies between 0 and 1 and gets
multiplied with the light intensity and material color to produce the
final diffuse color, as shown in Equation 4.1, “Diffuse Color”.
df is computed by taking
the dot product of the surface normal with the light direction vector,
then clamping the result to a non-negative number, as shown in Equation 4.2, “Diffuse Coefficient”.
The dot product is another operation that you might need a refresher on. When applied to two unit-length vectors (which is what we're doing for diffuse lighting), you can think of the dot product as a way of measuring the angle between the vectors. If the two vectors are perpendicular to each other, their dot product is zero; if they point away from each other, their dot product is negative. Specifically, the dot product of two unit vectors is the cosine of the angle between them. To see how to compute the dot product, here's a snippet from our C++ vector library (see Appendix A, C++ Vector Library for a complete listing):
template <typename T>
struct Vector3 {
// ...
T Dot(const Vector3& v) const
{
return x * v.x + y * v.y + z * v.z;
}
// ...
T x, y, z;
};
Warning
Don't confuse the dot product with the cross product! For one thing, cross products produce vectors, while dot products produce scalars.
With OpenGL ES 1.1, the math required for diffuse lighting is done for you behind the scenes; with 2.0, you have to do the math yourself in a shader. You'll learn both methods later in the chapter.
The L vector in Equation 4.2, “Diffuse Coefficient” can be computed like this:
In practice, you can often pretend that the light is so far away that all vertices are at the origin. The above equation then simplifies to:
When you apply this optimization, you're said to be using an infinite light source. Taking each vertex position into account is slower but more accurate; this is a positional light source.
Give it a Shine with Specular
I guess you could say the Overlook Hotel here has somethin' almost like "shining."
Diffuse lighting is not affected by the position of the camera; the diffuse brightness of a fixed point stays the same, no matter which direction you observe it from. This is in contrast to specular lighting, which moves the area of brightness according to your eye position, as seen in Figure 4.10, “Specular Lighting”. Specular lighting mimics the shiny highlight seen on polished surfaces. Hold a shiny apple in front of you and shift your head to the left and right; you'll see that the apple's shiny spot moves with you. Specular is more costly to compute than diffuse because it uses exponentiation to compute falloff. You choose the exponent according to how you want the material to look; the higher the exponent, the shinier the model.
The H vector in Figure 4.10, “Specular Lighting” is called the half-angle because it
divides the angle between the light and the camera in half. Much like
diffuse lighting, the goal is to compute an intensity coefficient (in
this case, sf) between 0 and 1. Equation 4.3, “Specular Lighting” shows how to compute
sf.
In practice, you can often pretend that the viewer is infinitely far from the vertex, in which case the E vector is substituted with (0, 0, 1). This technique is called infinite viewer. When E is used, this is called local viewer.
Adding Light to Model Viewer
We'll first add lighting to the OpenGL ES 1.1
backend since it's much less involved than the 2.0 variant. Example 4.10, “ES1::RenderingEngine::Initialize” shows the new
Initialize method (unchanged portions are replaced
with ellipses for brevity).
Example 4.10. ES1::RenderingEngine::Initialize
void RenderingEngine::Initialize(const vector<ISurface*>& surfaces)
{
vector<ISurface*>::const_iterator surface;
for (surface = surfaces.begin(); surface != surfaces.end(); ++surface) {
// Create the VBO for the vertices.
vector<float> vertices;
(*surface)->GenerateVertices(vertices, VertexFlagsNormals);
GLuint vertexBuffer;
glGenBuffers(1, &vertexBuffer);
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
glBufferData(GL_ARRAY_BUFFER,
vertices.size() * sizeof(vertices[0]),
&vertices[0],
GL_STATIC_DRAW);
// Create a new VBO for the indices if needed.
...
}
// Set up various GL state.
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_NORMAL_ARRAY);
glEnable(GL_LIGHTING);
glEnable(GL_LIGHT0);
glEnable(GL_DEPTH_TEST);
// Set up the material properties.
vec4 specular(0.5f, 0.5f, 0.5f, 1);
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, specular.Pointer());
glMaterialf(GL_FRONT_AND_BACK, GL_SHININESS, 50.0f);
m_translation = mat4::Translate(0, 0, -7);
}
Tell the
| |
Enable two vertex attributes: one for position, the other for surface normal. | |
Enable lighting and turn on the first
light source (known as | |
The default specular color is black, so here we set it to gray, and set the specular exponent to 50. We'll set diffuse later. |
Example 4.10, “ES1::RenderingEngine::Initialize”
uses some new OpenGL functions: glMaterialf and
glMaterialfv. These are only useful when lighting is
turned on, and they are unique to ES 1.1 — with 2.0 you'd use
glVertexAttrib instead. The declarations for these
functions are:
void glMaterialf(GLenum face, GLenum pname, GLfloat param); void glMaterialfv(GLenum face, GLenum pname, const GLfloat *params);
The face parameter is a
bit of a carry-over from desktop OpenGL, which allows the back and front
sides of a surface to have different material properties. For OpenGL ES,
this parameter must always be set to
GL_FRONT_AND_BACK.
The pname parameter can be
one of:
- GL_SHININESS
Specifies the specular exponent as a float between 0 and 128. This is the only parameter that you set with
glMaterialf; all other parameters requireglMaterialfvbecause they have four floats each.- GL_AMBIENT
This specifies the ambient color of the surface and requires four floats (red, green, blue, alpha). The alpha value is ignored, but I always set it to one just to be safe.
- GL_SPECULAR
This specifies the specular color of the surface and also requires four floats, although alpha is ignored.
- GL_EMISSION
This specifies the emission color of the surface. We haven't covered emission because it's so rarely used. It's similar to ambient except that it's unaffected by light sources. This can be useful for debugging; if you want to verify that a surface of interest is visible, set its emission color to white. Like ambient and specular, it requires 4 floats and alpha ignored.
- GL_DIFFUSE
This specifies the diffuse color of the surface and requires four floats. The final alpha value of the pixel originates from the diffuse color.
- GL_AMBIENT_AND_DIFFUSE
Using only one function call, this allows you to specify the same color for both ambient and diffuse.
When lighting is enabled, the final color of
the surface is determined at run time, so OpenGL ignores the color
attribute that you set with glColor4f or
GL_COLOR_ARRAY (see Table 2.2). Since you'd only specify the color
attribute when lighting is turned off, it's often referred to as
non-lit color.
Note
As an alternative to calling
glMaterialfv, you can embed diffuse and ambient
colors into the vertex buffer itself, through a mechanism called
color material. When enabled, this redirects the
non-lit color attribute into the GL_AMBIENT and
GL_DIFFUSE material parameters. You can enable it
by calling glEnable(GL_COLOR_MATERIAL).
Next we'll flesh out the
Render() method so that it uses normals, as shown in
Example 4.11, “ES1::RenderingEngine::Render”. New/changed lines are in
boldface. Note that we moved up the call to
glMatrixMode; this is explained further in the
callouts that follow the listing.
Example 4.11. ES1::RenderingEngine::Render
void RenderingEngine::Render(const vector<Visual>& visuals) const
{
glClearColor(0.5f, 0.5f, 0.5f, 1);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
vector<Visual>::const_iterator visual = visuals.begin();
for (int visualIndex = 0;
visual != visuals.end();
++visual, ++visualIndex)
{
// Set the viewport transform.
ivec2 size = visual->ViewportSize;
ivec2 lowerLeft = visual->LowerLeft;
glViewport(lowerLeft.x, lowerLeft.y, size.x, size.y);
// Set the light position.
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
vec4 lightPosition(0.25, 0.25, 1, 0);
glLightfv(GL_LIGHT0, GL_POSITION, lightPosition.Pointer());
// Set the model-view transform.
mat4 rotation = visual->Orientation.ToMatrix();
mat4 modelview = rotation * m_translation;
glLoadMatrixf(modelview.Pointer());
// Set the projection transform.
float h = 4.0f * size.y / size.x;
mat4 projection = mat4::Frustum(-2, 2, -h / 2, h / 2, 5, 10);
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(projection.Pointer());
// Set the diffuse color.
vec3 color = visual->Color * 0.75f;
vec4 diffuse(color.x, color.y, color.z, 1);
glMaterialfv(GL_FRONT_AND_BACK, GL_DIFFUSE, diffuse.Pointer());
// Draw the surface.
int stride = 2 * sizeof(vec3);
const Drawable& drawable = m_drawables[visualIndex];
glBindBuffer(GL_ARRAY_BUFFER, drawable.VertexBuffer);
glVertexPointer(3, GL_FLOAT, stride, 0);
const GLvoid* normalOffset = (const GLvoid*) sizeof(vec3);
glNormalPointer(GL_FLOAT, stride, normalOffset);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, drawable.IndexBuffer);
glDrawElements(GL_TRIANGLES, drawable.IndexCount, GL_UNSIGNED_SHORT, 0);
}
}
Set the position of
| |
The non-lit version of the app used
| |
Point OpenGL to the right place in the
VBO for obtaining normals. Position comes first and it's a
|
That's it! Figure 4.11, “Model Viewer with Lighting” depicts the app now that lighting has been
added. Since we haven't implemented the ES 2.0 renderer yet, you'll need
to enable the ForceES1 constant at the top of
GLView.mm.
Using Light Properties
Example 4.11, “ES1::RenderingEngine::Render” introduced a new OpenGL
function for modifying light parameters,
glLightfv:
GL_API void GL_APIENTRY glLightfv(GLenum light, GLenum pname, const GLfloat *params);
The light parameter
identifies the light source. Although we're using only one light source
in Model Viewer, up to eight are allowed (GL_LIGHT0 -
GL_LIGHT7).
The pname argument
specifies the light property to modify. OpenGL ES 1.1 supports ten light
properties:
- GL_AMBIENT, GL_DIFFUSE, GL_SPECULAR
As you'd expect, each of these takes four floats to specify a color. Note that light colors alone do not determine the hue of the surface; they get multiplied with the surface colors specified by
glMaterialfv.- GL_POSITION
The position of the light is specified with four floats. If you don't set the light's position, it defaults to (0, 0, 1, 0). The W component should be 0 or 1, where 0 indicates an infinitely distant light. Such light sources are just as bright as normal light sources, but their "rays" are parallel. This is computationally cheaper because OpenGL does not bother re-computing the L vector (see Figure 4.9, “Diffuse Lighting”) at every vertex.
- GL_SPOT_DIRECTION, GL_SPOT_EXPONENT, GL_SPOT_CUTOFF
You can restrict a light's area of influence to a cone using these parameters. Don't set these parameters if you don't need them; doing so can degrade performance. I won't go into detail about spotlights since they are somewhat esoteric to ES 1.1, and you can easily write a shader in ES 2.0 to achieve a similar effect. Consult an OpenGL reference to see how to use spotlights. (see the section called “Further Reading”)
- GL_CONSTANT_ATTENUATION, GL_LINEAR_ATTENUATION, GL_QUADRATIC_ATTENUATION
These parameters allow you to dim the light intensity according to its distance from the object. Much like spotlights, attenuation is surely covered in your favorite OpenGL reference book. Again, be aware that setting these parameters could impact your frame rate.
You may've noticed that the inside of the cone appears especially dark. This is because the normal vector is facing away from the light source. On third-generation iPhones and iPod Touches, you can enable a feature called two-sided lighting, which inverts the normals on back-facing triangles, allowing them to be lit. It's enabled like this:
glLightModelf(GL_LIGHT_MODEL_TWO_SIDE, GL_TRUE);
Use this function with caution, as it is not supported on older iPhones. One way to avoid two-sided lighting is to re-draw the geometry at a slight offset using flipped normals. This effectively makes your one-sided surface into a two-sided surface. For example, in the case of our cone shape, we could draw another equally-size cone that's just barely "inside" the original cone.
Note
Just like every other lighting function,
glLightModelf doesn't exist under ES 2.0. With ES
2.0, you can achieve two-sided lighting by using a special shader
variable called gl_FrontFacing. More on this
later.
Shaders Demystified
Before we add lighting to the ES 2.0 rendering engine of Model Viewer, let's go over some shader fundamentals. What exactly is a shader? In chapter 1, we mentioned that shaders are relatively small snippets of code that run on the graphics processor, and that thousands of shader instances can execute simultaneously.
Let's dissect the simple vertex shader that we've been using in our sample code so far, repeated here in Example 4.12, “Simple.vert”.
Example 4.12. Simple.vert
Declare two 4D floating-point vectors with
the | |
Declare a 4D floating-point vector as a varying. The vertex shader must write to all varyings that it declares — if it doesn't, OpenGL's shader compiler will report an error. The initial value is undefined. | |
Declare two 4x4 matrices as uniforms. Much like attributes, the vertex shader has read-only access to uniforms. But unlike vertex attributes, uniforms cannot change from one vertex to the next. | |
The entry point to the shader. Some shading
languages let you define your own entry point, but with GLSL, it's
always | |
No lighting or fancy math here, just pass
the | |
Here we transform the position by the
projection and model-view matrices, much like OpenGL ES 1.1
automatically does on our behalf. Note the usage of
|
The keywords attribute,
uniform, and varying are storage
qualifiers in GLSL. Table 4.1, “GLSL Storage Qualifiers” summarizes the five
storage qualifiers available in GLSL.
Table 4.1. GLSL Storage Qualifiers
One way to visualize the flow of shader data is shown in Figure 4.12, “Shader-Centric View of OpenGL (VS = Vertex Shader, FS = Fragment Shader)”. Be aware that this diagram is very simplified; for example, it does not include blocks for texture memory or program storage.
The fragment shader we've been using so far is incredibly boring:
Example 4.13. Boring Fragment Shader
Declares a 4D floating-point varying
(read-only) with | |
The entry point to every fragment shader is
its | |
|
Perhaps the most interesting new concept here
is the precision qualifier. Fragment shaders require a precision qualifier
for all floating-point declarations. The valid qualifiers are
lowp, mediump, and
highp. The GLSL specification gives implementations
some leeway in the underlying binary format that corresponds to each of
these qualifiers; specific details for the graphics processor in the
iPhone 3GS are shown in Table 4.2, “Floating-point Precision in 3rd Generation Devices”.
Note
An alternative to specifying precision in
front of every type is to supply a default using the
precision keyword. Vertex shaders implicitly have a
default floating-point precision of highp. To create
a similar default in your fragment shader, add precision highp
float; to the top of your shader.
Table 4.2. Floating-point Precision in 3rd Generation Devices
| Qualifier | Underlying Type | Range | Typical Usage |
|---|---|---|---|
| highp | 32-bit floating point | [−9.999999×1096,+9.999999×1096] | colors, normals |
| mediump | 16-bit floating point | [-65520, +65520] | texture coordinates |
| lowp | 10-bit fixed point | [-2, +2] | vertex positions, matrices |
Also of interest in Example 4.13, “Boring Fragment Shader” is the gl_FragColor
variable, which is a bit of a special case. It's a variable that is built
into the language itself, and always refers to the color that gets applied
to the framebuffer. The fragment shading language also defines the
following built-in variables:
- gl_FragData[0]
gl_FragDatais an array of output colors that has only one element. This exists in OpenGL ES only for compatibility reasons; usegl_FragColorinstead.- gl_FragCoord
Input variable that contains window coordinates for the current fragment, which is useful for image processing.
- gl_FrontFacing
Boolean input variable; true for front-facing primitives, false for back-faces. Use this to implement two-sided lighting.
- gl_PointCoord
This is an input texture coordinate that's used only for point sprite rendering; we'll cover it in the section called “Rendering Confetti, Fireworks, and More: Point Sprites”.
Adding Shaders to Model Viewer
OpenGL ES 2.0 does not automatically perform lighting math behind the scenes; instead it relies on developers to provide it with shaders that perform whatever type of lighting they desire. Let's come up with a vertex shader that mimics the math done by ES 1.1 when lighting is enabled.
To keep things simple, we'll use the infinite light source model for diffuse (the section called “Feeding OpenGL with Normals”) combined with the infinite viewer model for specular (the section called “Give it a Shine with Specular”). We'll also assume that the light is white. Pseudocode is shown in Example 4.14, “Basic Lighting Pseudocode”.
Example 4.14. Basic Lighting Pseudocode
vec3 ComputeLighting(vec3 normal)
{
N = NormalMatrix * normal
L = Normalize(LightPosition)
E = (0, 0, 1)
H = Normalize(L + E)
df = max(0, N ∙ L)
sf = max(0, N ∙ H)
sf = sf ^ Shininess
return AmbientMaterial + DiffuseMaterial * df + SpecularMaterial * sf
}Note the NormalMatrix
variable in the pseudocode; it would be silly to re-compute the
inverse-transpose of the model-view at every vertex, so we'll compute up
front in the application code, then pass it in as the
NormalMatrix uniform. In many cases, it happens to be
equivalent to the model-view, but we'll leave it to the application to
decide how to compute it.
Let's add a new file to the Model Viewer
project called SimpleLighting.vert for the lighting
algorithm. In Xcode, right-click the Shaders folder and choose
Add→New file. Select the Empty
File template in the Other category. Name
it SimpleLighting.vert and add
/Shaders after the project folder name in the
location field. Deselect the checkbox in the Targets list and click
Finish.
Example 4.15, “SimpleLighting.vert” translates the pseudocode into GLSL. To make the shader usable in a variety of situations, we use uniforms to store light position, specular and ambient properties. A vertex attribute is used to store the diffuse color; for many models, the diffuse color may vary on a per-vertex basis (although in our case, it does not). This would allow us to use a single draw call to draw a multi-colored model.
Warning
Remember, we're leaving out the STRINGIFY
macros in all shader listings from here on out, so take a look at Example 1.13, “Simple.vert” to see how to add that macro to this file.
Example 4.15. SimpleLighting.vert
attribute vec4 Position;
attribute vec3 Normal;
attribute vec3 DiffuseMaterial;
uniform mat4 Projection;
uniform mat4 Modelview;
uniform mat3 NormalMatrix;
uniform vec3 LightPosition;
uniform vec3 AmbientMaterial;
uniform vec3 SpecularMaterial;
uniform float Shininess;
varying vec4 DestinationColor;
void main(void)
{
vec3 N = NormalMatrix * Normal;
vec3 L = normalize(LightPosition);
vec3 E = vec3(0, 0, 1);
vec3 H = normalize(L + E);
float df = max(0.0, dot(N, L));
float sf = max(0.0, dot(N, H));
sf = pow(sf, Shininess);
vec3 color = AmbientMaterial + df * DiffuseMaterial + sf * SpecularMaterial;
DestinationColor = vec4(color, 1);
gl_Position = Projection * Modelview * Position;
}
Take a look back at the pseudocode in Example 4.14, “Basic Lighting Pseudocode”; the vertex shader is an implementation of that. The main difference is that GLSL requires you to qualify many of the variables as being attributes, uniforms, or varyings. Also note that in its final code line, Example 4.15, “SimpleLighting.vert” performs the standard transformation of the vertex position, just as it did for the non-lit case.
Warning
GLSL is a bit different from many other
languages in that it does not autopromote literals from integers to
floats. For example, max(0, myFloat) generates a
compile error, but max(0.0, myFloat) does not. On the
other hand, constructors for vector-based types do
perform conversion implicitly; it's perfectly legal to write either
vec2(0, 0) or vec3(0.0,
0.0).
New Rendering Engine
To create the ES 2.0 backend to Model Viewer, let's start with the ES 1.1 variant and make the following changes, some of which should be familiar by now:
Copy the contents of
RenderingEngine.ES1.cppintoRenderingEngine.ES2.cpp.Remove the _OES and OES suffixes from the FBO code.
Change the namespace from
ES1toES2.Change the two #includes to point to the ES2 folder rather than the ES1 folder.
Add in the
BuildShaderandBuildProgrammethods (see Example 1.18). You must change all instances ofRenderingEngine2toRenderingEnginebecause we are using namespaces to distinguish between the 1.1 and 2.0 renderers.Add declarations for
BuildShaderandBuildProgramto the class declaration as shown in Example 1.15, “RenderingEngine2 Declaration”.Add the
#includeforiostreamas shown in Example 1.15, “RenderingEngine2 Declaration”.
Now that the busy work is out of the way,
let's add declarations for the uniform handles and attribute handles
that are used to communicate with the vertex shader. Since the vertex
shader is now much more complex than the simple pass-through program
we've been using, let's group the handles into simple sub-structures, as
shown in Example 4.16, “ES2::RenderingEngine Structures”. Add this code
to RenderingEngine.ES2.cpp, within the namespace
declaration, not above it. (The bold part of the listing shows the two
lines you must add to the class declaration's
private: section.)
Example 4.16. ES2::RenderingEngine Structures
#define STRINGIFY(A) #A
#include "../Shaders/SimpleLighting.vert"
#include "../Shaders/Simple.frag"
struct UniformHandles {
GLuint Modelview;
GLuint Projection;
GLuint NormalMatrix;
GLuint LightPosition;
};
struct AttributeHandles {
GLint Position;
GLint Normal;
GLint Ambient;
GLint Diffuse;
GLint Specular;
GLint Shininess;
};
class RenderingEngine : public IRenderingEngine {
// ...
UniformHandles m_uniforms;
AttributeHandles m_attributes;
};
Next we need to change the Initialize method
so that it compiles the shaders, extracts the handles to all the
uniforms and attributes, and sets up some default material colors.
Replace everything from the comment // Set up various GL
state to the end of the method with the contents of Example 4.17, “ES2::RenderingEngine::Initialize()”.
Example 4.17. ES2::RenderingEngine::Initialize()
... // Create the GLSL program. GLuint program = BuildProgram(SimpleVertexShader, SimpleFragmentShader); glUseProgram(program); // Extract the handles to attributes and uniforms. m_attributes.Position = glGetAttribLocation(program, "Position"); m_attributes.Normal = glGetAttribLocation(program, "Normal"); m_attributes.Ambient = glGetAttribLocation(program, "AmbientMaterial"); m_attributes.Diffuse = glGetAttribLocation(program, "DiffuseMaterial"); m_attributes.Specular = glGetAttribLocation(program, "SpecularMaterial"); m_attributes.Shininess = glGetAttribLocation(program, "Shininess"); m_uniforms.Projection = glGetUniformLocation(program, "Projection"); m_uniforms.Modelview = glGetUniformLocation(program, "Modelview"); m_uniforms.NormalMatrix = glGetUniformLocation(program, "NormalMatrix"); m_uniforms.LightPosition = glGetUniformLocation(program, "LightPosition"); // Set up some default material parameters. glVertexAttrib3f(m_attributes.Ambient, 0.04f, 0.04f, 0.04f); glVertexAttrib3f(m_attributes.Specular, 0.5, 0.5, 0.5); glVertexAttrib1f(m_attributes.Shininess, 50); // Initialize various state. glEnableVertexAttribArray(m_attributes.Position); glEnableVertexAttribArray(m_attributes.Normal); glEnable(GL_DEPTH_TEST); // Set up transforms. m_translation = mat4::Translate(0, 0, -7);
Next let's replace the
Render() method, shown in Example 4.18, “ES2::RenderingEngine::Render()”.
Example 4.18. ES2::RenderingEngine::Render()
void RenderingEngine::Render(const vector<Visual>& visuals) const
{
glClearColor(0, 0.125f, 0.25f, 1);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
vector<Visual>::const_iterator visual = visuals.begin();
for (int visualIndex = 0;
visual != visuals.end();
++visual, ++visualIndex) {
// Set the viewport transform.
ivec2 size = visual->ViewportSize;
ivec2 lowerLeft = visual->LowerLeft;
glViewport(lowerLeft.x, lowerLeft.y, size.x, size.y);
// Set the light position.
vec4 lightPosition(0.25, 0.25, 1, 0);
glUniform3fv(m_uniforms.LightPosition, 1, lightPosition.Pointer());
// Set the model-view transform.
mat4 rotation = visual->Orientation.ToMatrix();
mat4 modelview = rotation * m_translation;
glUniformMatrix4fv(m_uniforms.Modelview, 1, 0, modelview.Pointer());
// Set the normal matrix.
// It's orthogonal, so its Inverse-Transpose is itself!
mat3 normalMatrix = modelview.ToMat3();
glUniformMatrix3fv(m_uniforms.NormalMatrix, 1,
0, normalMatrix.Pointer());
// Set the projection transform.
float h = 4.0f * size.y / size.x;
mat4 projectionMatrix = mat4::Frustum(-2, 2, -h / 2, h / 2, 5, 10);
glUniformMatrix4fv(m_uniforms.Projection, 1,
0, projectionMatrix.Pointer());
// Set the diffuse color.
vec3 color = visual->Color * 0.75f;
glVertexAttrib4f(m_attributes.Diffuse, color.x,
color.y, color.z, 1);
// Draw the surface.
int stride = 2 * sizeof(vec3);
const GLvoid* offset = (const GLvoid*) sizeof(vec3);
GLint position = m_attributes.Position;
GLint normal = m_attributes.Normal;
const Drawable& drawable = m_drawables[visualIndex];
glBindBuffer(GL_ARRAY_BUFFER, drawable.VertexBuffer);
glVertexAttribPointer(position, 3, GL_FLOAT,
GL_FALSE, stride, 0);
glVertexAttribPointer(normal, 3, GL_FLOAT, GL_FALSE,
stride, offset);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, drawable.IndexBuffer);
glDrawElements(GL_TRIANGLES, drawable.IndexCount,
GL_UNSIGNED_SHORT, 0);
}
}
That's it for the ES 2.0 backend! Turn off
the ForceES1 switch in GLView.mm
and you should see something very similar to the ES 1.1 screenshot shown
in Figure 4.11, “Model Viewer with Lighting”.
Per-Pixel Lighting
When a model has coarse tessellation, performing the lighting calculations at the vertex level can result in the loss of specular highlights and other detail, as seen in Figure 4.13, “Tesselation and Lighting. From left to right: infinite tesselation, vertex lighting, and pixel lighting.”.
Figure 4.13. Tesselation and Lighting. From left to right: infinite tesselation, vertex lighting, and pixel lighting.
One technique to counteract this unattractive effect is per-pixel lighting; this is when most (or all) of the lighting algorithm takes place in the fragment shader.
Warning
Shifting work from the vertex shader to the pixel shader can often be detrimental to performance. I encourage you to experiment with performance before you commit to a specific technique.
The vertex shader becomes vastly simplified, as shown in Example 4.19, “PixelLighting.vert”. It simply passes the diffuse color and eye-space normal to the fragment shader.
Example 4.19. PixelLighting.vert
attribute vec4 Position;
attribute vec3 Normal;
attribute vec3 DiffuseMaterial;
uniform mat4 Projection;
uniform mat4 Modelview;
uniform mat3 NormalMatrix;
varying vec3 EyespaceNormal;
varying vec3 Diffuse;
void main(void)
{
EyespaceNormal = NormalMatrix * Normal;
Diffuse = DiffuseMaterial;
gl_Position = Projection * Modelview * Position;
}
The fragment shader now performs the burden
of the lighting math, as shown in Example 4.20, “PixelLighting.frag”.
The main distinction it has from its per-vertex counterpart (Example 4.15, “SimpleLighting.vert”) is the presence of precision
specifiers throughout. We're using lowp for colors,
mediump for the varying normal, and
highp for the internal math.
Example 4.20. PixelLighting.frag
varying mediump vec3 EyespaceNormal;
varying lowp vec3 Diffuse;
uniform highp vec3 LightPosition;
uniform highp vec3 AmbientMaterial;
uniform highp vec3 SpecularMaterial;
uniform highp float Shininess;
void main(void)
{
highp vec3 N = normalize(EyespaceNormal);
highp vec3 L = normalize(LightPosition);
highp vec3 E = vec3(0, 0, 1);
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 * Diffuse + sf * SpecularMaterial;
gl_FragColor = vec4(color, 1);
}Note
To try these out, you can replace the contents of your existing
.vert and .frag files. Just
be sure not to delete the first line with STRINGIFY or the last line
with the closing parenthesis and semicolon.
Shifting work from the vertex shader to the fragment shader was simple enough, but watch out: we're dealing with the normal vector in a sloppy way. OpenGL performs linear interpolation on each component of each varying. This causes inaccurate results, as you might recall from the coverage of quaternions in Chapter 3, Vertices and Touch Points. Pragmatically speaking, simply re-normalizing the incoming vector is often good enough. We'll cover a more rigorous way of dealing with normals when we present bump mapping in the section called “Bump Mapping and Dot3 Lighting”.
Toon Shading
Mimicking the built-in lighting functionality in ES 1.1 gave us a fairly painless segue to the world of GLSL. We could continue mimicking more and more ES 1.1 features, but that would get tiresome. After all, we're upgrading to ES 2.0 to enable new effects, right? Let's leverage shaders to create a simple effect that would otherwise be difficult (if not impossible) to achieve with ES 1.1.
Toon Shading (sometimes Cel Shading) achieves a cartoony effect by limiting gradients to two or three distinct colors, as shown in Figure 4.14, “Toon Shading”.
Assuming you're already using per-pixel lighting, achieving this is actually incredibly simple; just add the boldface lines in Example 4.21, “ToonShading.frag”.
Example 4.21. ToonShading.frag
varying mediump vec3 EyespaceNormal;
varying lowp vec3 Diffuse;
uniform highp vec3 LightPosition;
uniform highp vec3 AmbientMaterial;
uniform highp vec3 SpecularMaterial;
uniform highp float Shininess;
void main(void)
{
highp vec3 N = normalize(EyespaceNormal);
highp vec3 L = normalize(LightPosition);
highp vec3 E = vec3(0, 0, 1);
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);
if (df < 0.1) df = 0.0;
else if (df < 0.3) df = 0.3;
else if (df < 0.6) df = 0.6;
else df = 1.0;
sf = step(0.5, sf);
lowp vec3 color = AmbientMaterial + df * Diffuse + sf * SpecularMaterial;
gl_FragColor = vec4(color, 1);
}
Better Wireframes using Polygon Offset
The toon shading example belongs to a class of effects called non-photorealistic effects, often known as NPR effects. Having dangled the carrot of shaders in front of you, I'd now like to show that ES 1.1 can also render some cool effects.
For example, you might want to produce a intentionally faceted look to better illustrate the geometry; these is useful in applications like CAD visualization or technical illustration. Figure 4.15, “Two-Pass Wireframe with the Trefoil Knot and Möbius Strip” shows off a two-pass technique whereby the model is first rendered with triangles, then with lines. The result is less messy than the Wireframe Viewer app because hidden lines have been eliminated.
An issue with this two-pass technique is Z-fighting (see the section called “Beware the Scourge of Depth Artifacts”). An obvious workaround is translating the first pass backwards ever so slightly, or translating the second pass forwards. Unfortunately, that approach causes issues due to the non-linearity of depth precision; some portions of your model would look fine, but other parts may have lines on the opposite side that poke through.
Turns out that both versions of OpenGL ES offer a solution to this specific issue, and it's called polygon offset. Polygon offset tweaks the Z value of each pixel according to the depth slope of the triangle that it's in. You can enable and set it up like so:
glEnable(GL_POLYGON_OFFSET_FILL); glPolygonOffset(factor, units);
factor scales the depth
slope, and units gets added to the result. When polygon
offset is enabled, the Z values in each triangle get tweaked as
follows:
The code to implement this effect in Model
Viewer can be found in the downloadable examples (see the section called “How to Contact Us”). Note that your
RenderingEngine class will need to store two VBO
handles for index buffers: one for the line indices, the other for the
triangle indices. In practice, finding the right values for
factor and units almost
always requires experimentation.
Warning
Due to a hardware limitation, first and
second generation iPhones ignore the factor argument
in glPolygonOffset.
Loading Geometry From OBJ Files
So far we've dealing exclusively with a gallery of parametric surfaces. They make a great teaching tool, but parametric surfaces probably aren't what you'll be rendering in your app. More likely, you'll have 3D assets coming from artists who use modelling software such as Maya or Blender.
The first thing to decide on is the file format we'll use for storing geometry. The COLLADA format was devised to solve the problem of interchange between various 3D packages, but COLLADA is quite complex; it's capable of conveying much more than just geometry, including effects, physics and animation.
A more suitable format for our modest purposes is the simple OBJ format, first developed by Wavefront Technologies in the 1980's and still in use today. We won't go into its full specification here (there are plenty of relevant sources on the web), but we'll cover how to load a conformant file that uses a subset of OBJ features.
Warning
Even though the OBJ format is simple and portable, I don't recommend using it in a production game or application. The parsing overhead can be avoided by inventing your own raw binary format, slurping up the entire file in a single I/O call, then directly uploading its contents into a vertex buffer. This type of blitz loading can greatly improve the start-up time of your iPhone app.
Note
Another popular geometry file format for the iPhone is PowerVR's POD format. The PowerVR Insider SDK (discussed in Chapter 5, Textures and Image Capture) includes tools and code samples for generating and reading POD files.
Without further ado, Example 4.22, “Insanely Simple OBJ File” shows an example OBJ file.
Example 4.22. Insanely Simple OBJ File
# This is a comment. v 0.0 1.0 1.0 v 0.0 -1.0 1.0 v 0.0 -1.0 -1.0 v -1.0 1.0 1.0 f 1 2 3 f 2 3 4
Lines that start with a v
specify a vertex position using three floats separated by spaces. Lines
that start with f specify a "face" with a list of
indices into the vertex list. If the OBJ consists of triangles only, then
every face has exactly three indices, which makes it a breeze to render
with OpenGL. Watch out though: in OBJ files, indices are one-based, not
zero-based as they are in OpenGL.
OBJ also supports vertex normals with lines
that start with vn. For a face to refer to a vertex
normal, it references it using an index that's separate from the vertex
index, as shown in Example 4.23, “An OBJ File with Vertex Normals”. The slashes are doubled
because the format is actually f v/vt/vn; this example
doesn't use texture coordinates (vt) so it's
blank.
Example 4.23. An OBJ File with Vertex Normals
v 0.0 1.0 1.0 v 0.0 -1.0 1.0 v 0.0 -1.0 -1.0 vn 1 0 0 f 1//1 2//1 3//1
One thing that's a bit awkward about this (from an OpenGL standpoint) is that each face specifies separate position indices and normal indices. In OpenGL ES, you only specify a single list of indices; each index simultaneously refers to both a normal and a position.
Because of this complication, the normals found in OBJ files are often ignored in many tools. It's fairly easy to compute the normals yourself analytically, which we'll demonstrate soon.
3D artist Christopher Desse has graciously donated some models to the public domain, two of which we'll be using in Model Viewer: a character named "MicroNapalm" (the selected model in Figure 4.16, “Model Viewer with Two OBJ Models”) and a ninja character (far left in the Tab Bar). This greatly enhances the cool factor when you want to show off to your four year-old; why have cones and spheres when you can have ninjas?
Note
I should also mention that I processed
Christopher's OBJ files so that they only contain v
lines and f lines with three indices each, and that I
scaled the models to fit inside a unit cube.
Managing Resource Files
Note that we'll be loading resources from
external files for the first time. Adding file resources to a project is
easy in Xcode. Download the two files
(micronapalmv2.obj and
Ninja.obj) from the examples site and put them on
your desktop or Downloads folder.
Create a new folder called "Models" by right-clicking the ModelViewer root in the Overview pane and choose Add→New Group. Right-click the new folder and choose Add→Existing Files. Select the two OBJ files (available from the book's web site) by holding the command key, then click Add. In the next dialog box, check the box labeled "Copy items...", and accept the defaults, then click Add. Done!
The iPhone differs from other platforms in
how it handles bundled resources, so it makes sense to create a new
interface to shield this from the application engine. Let's call it
IResourceManager, shown in Example 4.24, “Adding IResourceManager to Interface.hpp”. For now it has a single method that
simply returns the absolute path to the folder that has resource files.
This may seem too simple to merit its own interface at the moment, but
we'll extend it in future chapters to handle more complex tasks, such as
loading in image files. Add these lines and make the change shown in
bold to Interface.hpp.
Example 4.24. Adding IResourceManager to Interface.hpp
#include <string>
using std::string;
// ...
struct IResourceManager {
virtual string GetResourcePath() const = 0;
virtual ~IResourceManager() {}
};
IResourceManager* CreateResourceManager();
IApplicationEngine* CreateApplicationEngine(IRenderingEngine* renderingEngine,
IResourceManager* resourceManager);
// ...We added a new argument to
CreateApplicationEngine to allow the
platform-specific layer to pass in its implementation class. In our case
the implementation class needs to be a mixture of C++ and Objective C.
Add a new C++ file to your Xcode project called
ResourceManager.mm (don't create the corresponding
.h file), shown in Example 4.25, “ResourceManager implementation”.
Example 4.25. ResourceManager implementation
#import <UIKit/UIKit.h>
#import <QuartzCore/QuartzCore.h>
#import <string>
#import <iostream>
#import "Interfaces.hpp"
using namespace std;
class ResourceManager : public IResourceManager {
public:
string GetResourcePath() const
{
NSString* bundlePath = [[NSBundle mainBundle] resourcePath];
return [bundlePath UTF8String];
}
};
IResourceManager* CreateResourceManager()
{
return new ResourceManager();
}
Retrieve the global
/Users/username/Library/Application Support/iPhone Simulator/User/Applications/uuid/ModelViewer.app When running on a physical device, it returns something like this: /var/mobile/Applications/uuid/ModelViewer.app | |
Convert the Objective C string object
into a C++ STL string object using the |
The resource manager should be instanced
within the GLView class and passed to the application
engine. GLView.h has a field called
m_resourceManager which gets instanced somewhere in
initWithFrame, and gets passed to
CreateApplicationEngine. (This is similar to how
we're already handling the rendering engine.) So you'll need to do the
following:
In
GLView.h, add the lineIResourceManager* m_resourceManager;to the@privatesection.In
GLView.mm, add the linem_resourceManager = CreateResourceManager();toinitWithFrame(you can add it just above the lineif (api == kEAGLRenderingAPIOpenGLES1). Next, addm_resourceManageras the second argument toCreateApplicationEngine.
Next we need to make a few small changes to
the application engine per Example 4.26, “Consuming IResourceManager from ApplicationEngine”.
The lines in bold show how we're re-using the
ISurface interface to avoid changing any code in the
rendering engine. Modified/new lines in
ApplicationEngine.cpp are shown in bold (make sure
you replace the existing assignments to surfaces[0]
and surfaces[0] in
Initialize):
Example 4.26. Consuming IResourceManager from ApplicationEngine
#include "Interfaces.hpp" #include "ObjSurface.hpp" ... class ApplicationEngine : public IApplicationEngine { public: ApplicationEngine(IRenderingEngine* renderingEngine, IResourceManager* resourceManager); ... private: ... IResourceManager* m_resourceManager; }; IApplicationEngine* CreateApplicationEngine(IRenderingEngine* renderingEngine, IResourceManager* resourceManager) { return new ApplicationEngine(renderingEngine, resourceManager); } ApplicationEngine::ApplicationEngine(IRenderingEngine* renderingEngine, IResourceManager* resourceManager) : m_spinning(false), m_pressedButton(-1), m_renderingEngine(renderingEngine), m_resourceManager(resourceManager) { ... } void ApplicationEngine::Initialize(int width, int height) { ... string path = m_resourceManager->GetResourcePath(); surfaces[0] = new ObjSurface(path + "/micronapalmv2.obj"); surfaces[1] = new ObjSurface(path + "/Ninja.obj"); surfaces[2] = new Torus(1.4, 0.3); surfaces[3] = new TrefoilKnot(1.8f); surfaces[4] = new KleinBottle(0.2f); surfaces[5] = new MobiusStrip(1); ... }
Implementing ISurface
The next step is creating the
ObjSurface class, which implements all the
ISurface methods, and is responsible for parsing the
OBJ file. This class will be more than just a dumb loader; recall that
we want to compute surface normals analytically. Doing so allows us to
reduce the size of the app, but at the cost of a slightly longer startup
time.
We'll compute the vertex normals by first finding the facet normal of every face, then averaging together the normals from adjoining faces. The C++ implementation of this algorithm is fairly rote, and you can get it from the book's companion website (see the section called “How to Contact Us”); for brevity's sake, pseudocode is shown in Example 4.27, “Pseudocode to Compute Vertex Normals from Facets”.
Example 4.27. Pseudocode to Compute Vertex Normals from Facets
ivec3 faces[faceCount] = read from OBJ
vec3 positions[vertexCount] = read from OBJ
vec3 normals[vertexCount] = { (0,0,0), (0,0,0), ... }
for each face in faces:
vec3 a = positions[face.Vertex0]
vec3 b = positions[face.Vertex1]
vec3 c = positions[face.Vertex2]
vec3 facetNormal = (a - b) × (c - b)
normals[face.Vertex0] += facetNormal
normals[face.Vertex1] += facetNormal
normals[face.Vertex2] += facetNormal
for each normal in normals:
normal = normalize(normal)
The mechanics of loading face indices and
vertex positions from the OBJ file is somewhat tedious, so you should
download ObjSurface.cpp and
ObjSurface.hpp from the book's website (see the section called “How to Contact Us”) and add them to your Xcode project. The
ObjSurface constructor is shown in Example 4.28, “ObjSurface Constructor”, which loads in the vertex indices using the
fstream facility in C++. Note that I subtracted one from all vertex
indices; watch out for the one-based pitfall!
Example 4.28. ObjSurface Constructor
ObjSurface::ObjSurface(const string& name) :
m_name(name),
m_faceCount(0),
m_vertexCount(0)
{
m_faces.resize(this->GetTriangleIndexCount() / 3);
ifstream objFile(m_name.c_str());
vector<ivec3>::iterator face = m_faces.begin();
while (objFile) {
char c = objFile.get();
if (c == 'f') {
assert(face != m_faces.end() && "parse error");
objFile >> face->x >> face->y >> face->z;
*face++ -= ivec3(1, 1, 1);
}
objFile.ignore(MaxLineSize, '\n');
}
assert(face == m_faces.end() && "parse error");
}
Wrapping Up
We covered quite a bit of territory in this chapter: we took a deep dive into GLSL, examined some algorithms for lighting and simple effects, and finally managed to replace some tiresome mathematical shapes with artistic 3D content. Another type of content that artists can offer is actually 2D, and yet it's a vital ingredient to almost all real-time 3D rendering. Textures are images that get "wrapped" around the objects in your scene. They sound simple enough, but trust me, they're thorny enough to deserve their own chapter or two. And so we come to the next stop on our graphics trek!




























Add a comment



Add a comment