|
This document describes how to render the basic geometry of a Quake 3 map using OpenGL. It describes how to texture and lightmap this geometry, but does not describe how to render shaders, effects, characters, or movers (elevators and doors).
This is intended as a sequel to Kekoa Proudfoot's Unofficial Quake 3 Map Specs document, which describes how to parse BSP files. It therefore uses the same notation.
This is an unofficial document. Quake 3 is a registered trademark of id Software, which does not sponsor, authorize, or endorse this document.
This document describes the Quake 3 BSP file format as the author
understands it. While every effort has been made to ensure that the
contents of this document are accurate, the author does not guarantee that
any portion of this document is actually correct. In addition, the author
cannot be held responsible the consequences of the any use or misuse of the
information contained in this document.
|
The rendering process has five steps:
The first two steps do not involve any OpenGL calls. Step 3 renders a cube centered at the viewer with a pre-warped texture to create the illusion of a detailed 3D environment. The practice of creating and rendering sky boxes is discussed elsewhere and is not detailed further in this document. Steps 4 and 5 render the actual visible surfaces of the map. The opaque list contains triangles that will be rendered without alpha blending. It is sorted from front to back to take advantage of early-out depth tests on newer graphics hardware. The transparent list contains alpha blended surfaces which must be rendered from back to front to generate a correct image. A straightforward quicksort on the camera-space depth of first vertex of each surface is sufficient for these purposes. For the kinds of maps involved, splitting overlapping polygons for truly correct render order or using a radix sort for faster sorting will only increase the complexity of a renderer with improving the resulting image quality or frame rate.
1. Determine the set of visible faces.
2. Partition the visible faces into transparent and opaque lists.
3. Clear the frame buffer and render the sky box
4. Render the opaque list in front-to-back order.
5. Render the transparent list in back-to-front order.
The Visible Surface Determination section explains how to use the BSP tree and precomputed visibility data to find visible faces.
The remaining steps are straightforward and not discussed in detail,
except for the actual face rendering. The vertex indexing scheme of
Quake 3 files can be confusing because there are two levels of
indirection. The Rendering Faces
section explains how to generate triangle sets from
the indices stored in a face.
|
A Quake 3 map contains multiple models. The first of these is always a static mesh that is the map itself and the remainders are "movers" like doors and elevators. This document is restricted to model[0]; it does not address the movers.
Assume the in-memory data structures mimic those in the file structure, and that the overarching class is named Q3Map. There are a few cases where I used a Vector3 in this document instead of float[3] to make the code easier to read. Not all data from the file is used for rendering. For example, the brushs are used for collision detection but ignored during rendering. The subset of data used during rendering is (links are to Proudfoot's structure definitions):
Lump Index | Lump Name | Description |
---|---|---|
1 | Textures | Surface descriptions (assume these have been converted to OpenGL textures). |
2 | Planes | Planes used by map geometry. |
3 | Nodes | BSP tree nodes. |
4 | Leaves | BSP tree leaves. |
5 | Leaffaces | Lists of face indices, one list per leaf. |
7 | Models | Descriptions of rigid world geometry in map (we only use model[0]). |
10 | Vertexes | Vertices used to describe faces. |
11 | Meshverts | Lists of offsets, one list per mesh. |
13 | Faces | Surface geometry. |
14 | Lightmaps | Packed lightmap data (assume these have been converted to an OpenGL texture) |
16 | Visdata | Cluster-cluster visibility data. |
There are additional data used during rendering that do not appear in the file. These are:
Camera camera
A camera description that contains viewer position and frustum
parameters. The Camera class must have accessors for these parameters
and a method, isVisible(float min[3], float max[3]). This method
returns true if the world space bounding box with the specified
corners has non-zero intersection with the camera's view frustum.
Set<int> alreadyVisible
Set of indices of faces that are already visible. This is used to
prevent the same face from being rendered multiple times. A general
set implementation is not necessary. Because the face indices are
consecutive integers, a bit-set can provide an efficient
implementation.
Array<int> visibleFace
Set of indices of faces that are visible; that is, the members of the
alreadyVisible set. For efficiency this is maintained as a separate
array instead of iterating through the set.
Array<Patch> patch
Patches are Faces that
describe sets of biquadratic Bezier surfaces. Each Patch contains an
array of Bezier instances, which are described later
in this document.
These Beziers are tessellated into triangles during loading so they
can be rendered as triangle strips. Your implementation must create
this tessellation and add an index into the patch array for patch
faces.
|
Quake 3 uses a coordinate system where the x-axis points East, the y-axis points South, and the z-axis points vertically downward. If you prefer a coordinate system where the y-axis points vertically upward and the z-axis points South, you can use the following function to convert from Quake 3 coordinates to your coordinate system.
void swizzle(Vector3& v) { float temp = v.y; v.y = v.z; v.z = -temp; } |
When swizzling data, you must convert the vertex positions, vertex normals, plane normals, and all bounding box min and max vectors. The Quake coordinate system is also scaled so that one meter is about 0.03 units. You may wish to change this scale factor. If you scale vertex positions remember to also scale plane distances, and min and max vectors appropriately.
Depending on the conventions of your rendering system, you may also want to invert Quake's lightmap texture coordinates to (1 - s, 1 - t) or (s, 1 - t). It is usually easy to tell when light map texture coordinates need to be inverted by looking at a rendering.
|
The input to the visible face determination step is the camera (and the map). The output is the visibleFace array, which contains the indices of all faces that are potentially visible to that camera. During the step, the alreadyVisible set is used to prevent a face index from being added to the visibleFace array more than once.
Two notes before we look at this process in more detail. First, the output is an array of potentially visible faces. A z-buffer test and frustum clipping (both typically provided by hardware) are still needed to generate the exactly visible set. Second, as Max McGuire says in his Quake 2 BSP File Format,
Many people incorrectly associate the BSP tree with the visibility algorithm used by Quake and similar engines. As described above, the visible surface determination is done using a precomputed PVS. The BSP tree is primarily used to divide the map into regions and to quickly determine which region the camera is in. As a result, it isn't that fundamental to any of the rendering algorithms used in Quake and any data structure giving a spatial subdivision (like an octree or a k-D tree) could be used instead. BSP trees are very simple however, and they are useful for some of the other non-rendering tasks in the Quake engine.
To determine the set of visible faces:
1. Find visCluster, the index of the cluster containing the camera position.
2. Select all leaves visible from that cluster.
3. Iterate through all faces in those clusters.
Find the camera cluster (visCluster)
Recall that a Quake 3 map is divided into convex spaces called
leaves. Adjacent leaves are joined into clusters. The
map file contains precomputed visibility information at the cluster
level, which is stored in the visData bit
array.
The index of the cluster containing the camera is
The root node is index 0 in the nodeArray. Each node has a splitting
plane associated with it. This plane divides space into two child
subnodes. If the camera lies in front of the splitting plane, recurse
into the front node. Otherwise recurse into the back node. We repeat
this process until a BSP leaf is reached.
In the Node data structure, a leaf is denoted by a negative child node
index. To convert the negative index into a legal leaf index, negate
and subtract one.
The following function takes a camera position as input. It walks the
BSP tree until a leaf is found and then returns the index of that
leaf. Remember that this is return value is a leaf index, not a
cluster index. The cluster index is stored in the leaf.
leaf[index].cluster
, where index is the index of
the leaf containing the camera. To find the index of the leaf
containing the camera, walk the BSP tree.
int Q3Map::findLeaf(const Vector3& camPos) const { int index = 0; while (index >= 0) { const Node& node = nodeArray[index]; const Plane& plane = planeArray[node.plane]; // Distance from point to a plane const double distance = plane.normal.dot(camPos) - plane.distance; if (distance >= 0) { index = node.front; } else { index = node.back; } } return -index - 1; } |
bool Q3Map::isClusterVisible(int visCluster, int testCluster) const { if ((visData.bitsets == NULL) || (visCluster < 0)) { return true; } int i = (visCluster * visData.bytesPerCluster) + (testCluster >> 3); uint8 visSet = visData.bitsets[i]; return (visSet & (1 << (testCluster & 7))) != 0; } |
In the function, the expression (testCluster >> 3) computes (testCluster / 8), i.e. the byte within visData that contains information about the given cluster. The expression (1 << (testCluster & 7)) creates a bit mask that selects bit (testCluster mod 8) within that byte.
The visData information only considers the position of the viewer and
not the orientation. Orientation is handled by the frustum culling
step. The leaf contains two corners of its bounding box, min and max.
If camera.isVisible(leaf[L].min, leaf[L].max)
returns
false, the leaf should be dropped from consideration because it is
outside the view frustum. Note that some of the faces in the leaf are
also in adjacent leaves and may therefore still be visible-- the other
leaves will take care of that when we iterate through them.
Iterate through faces
A leaf contains all faces that have non-zero intersection with the
leaf volume. The faces in leaf with index L have indices
Because a face may protrude out of the leaf, the same face may be in
multiple leaves. Use the alreadyVisible
set to avoid touching the same face twice. A simple code snippet for
this is:
leaf[L].firstFace
through (leaf[L].firstFace +
leaf[L].facesCount - 1)
.
for (int i = 0; i < leaf[L].facesCount; ++i) { const int f = i + leaf[L].firstFace; if (! alreadyVisible.contains(f)) { alreadyVisible.insert(f); visibleFaces.append(f); } } |
|
There are four kinds of Faces in a Quake 3 map: polygons, patches, meshes, and billboards. Polygons and meshes are collections of triangles. A patch is a bezier-spline patch that must be tessellated into triangles for rendering. Billboards are polygons whose orientation changes to always face the viewer.
Polygons and meshes are rendered in the same manner. The position, texture coordinate, and lightmap coordinate are stored in the vertex array. Using OpenGL vertex arrays, these can be referenced with a single index by setting the active vertex pointer and tex coord pointers into the same array, offset by the memory location within a Vertex for each type of coordinate.
Patches are tessellated into triangles, either during loading or per-frame, and rendered as triangle strips. The tessellation process creates vertices that did not exist in the original mesh, so each patch contains its own vertex array instead of using the global one stored in the map file.
Although handling the shaders and effects that can be stored in Quake
3 maps is more complicated, simple alpha blending can be supported to
render translucent surfaces correctly. When a texture contains an
alpha channel, enable blending and select the
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
blending mode. Alpha blended faces should not be backfaced culled;
they appear to have only one polygon for both sides (there is probably
a two-sided polygon flag somewhere that is the correct place to obtain
such information).
Render a mesh
Each face mesh, curFace of type Face describes a mesh
containing (curFace.meshVertexesCount / 3) triangles. The indices
into the vertex array for the vertices of these triangles are
themselves indirected. The meshVertex
array stores the indices of the vertices, in the correct order to
create triangle lists. For a given face, these are
This indexing scheme, although confusing, is arranged conveniently for
using glDrawElements to render the triangle lists. The following code
renders a mesh using this function.
meshVertex[curFace.firstMeshVertex]
through
meshVertex[curFace.firstMeshVertex + curFace.mushVertexesCount -
1]
. The meshVertex values are also offet by
curFace.firstVertex
.
const Face& curFace = face[visibleFace[f]]; static const stride = sizeof(Vertex); // BSP Vertex, not float[3] const int offset = face.firstVertex; glVertexPointer(3, GL_FLOAT, stride, &(vertex[offset].position)); glClientActiveTextureARB(GL_TEXTURE0_ARB); glTexCoordPointer(2, GL_FLOAT, stride, &(vertex[offset].textureCoord)); glClientActiveTextureARB(GL_TEXTURE1_ARB); glTexCoordPointer(2, GL_FLOAT, stride, &(vertex[offset].lightmapCoord)); glDrawElements(GL_TRIANGLES, curFace.meshVertexesCount, GL_UNSIGNED_INT, &meshVertex[curFace.firstMeshVertex]); |
In the above code, the firstMeshVertex offset is applied directly to
the vertex pointer since there is no other provision for offsetting
the indices with glDrawElements.
Render a patch
Patches are surfaces defined by an array of Bezier curves. These
curves are represented by the following data structure.
class Bezier { private: int level; Array<Vertex> vertex; Array<uint32> indexes; Array<int32> trianglesPerRow; Array<uint32*> rowIndexes; public: Vertex controls[9]; void tessellate(int level); void render(); }; |
The controls array contains the 9 control points for this curve. The
Beziers form a grid within a patch, so adjacent Beziers will share
three of these. The Bezier curves must be tessellated prior to
rendering. The level of the tessellation is the number of
edges into which each side of a 2D curve is subdivided. The total
number of triangles in the tessellation is (2 * pow(level,
2))
. The remainder of the private data is the tessellation,
stored in a form we can pass directly to glMultiDrawElements. The
pointers in the rowIndexes array point into the indexes array; they
are not referring to separately allocated memory.
The tessellate method computes the private data for rendering from the control points (which must themselves be set up during loading of the containing patch). Any number between five and 10 is a reasonable subdivision level for most maps. The intent of subdivision is to provide smoother curves on faster machines by increasing the level at runtime. Another use for subdivision is to allocate more polygons to larger curves-- implementors are free to provide their own metric for choosing a good subdivision level.
The following is an implementation of the tessellate method with structure based on the tessellator in the Paul Baker's Octagon project.
void Bezier::tessellate(int L) { level = L; // The number of vertices along a side is 1 + num edges const int L1 = L + 1; vertex.resize(L1 * L1); // Compute the vertices int i; for (i = 0; i <= L; ++i) { double a = (double)i / L; double b = 1 - a; vertex[i] = controls[0] * (b * b) + controls[3] * (2 * b * a) + controls[6] * (a * a); } for (i = 1; i <= L; ++i) { double a = (double)i / L; double b = 1.0 - a; BSPVertex temp[3]; int j; for (j = 0; j < 3; ++j) { int k = 3 * j; temp[j] = controls[k + 0] * (b * b) + controls[k + 1] * (2 * b * a) + controls[k + 2] * (a * a); } for(j = 0; j <= L; ++j) { double a = (double)j / L; double b = 1.0 - a; vertex[i * L1 + j]= temp[0] * (b * b) + temp[1] * (2 * b * a) + temp[2] * (a * a); } } // Compute the indices int row; indexes.resize(L * (L + 1) * 2); for (row = 0; row < L; ++row) { for(int col = 0; col <= L; ++col) { indexes[(row * (L + 1) + col) * 2 + 1] = row * L1 + col; indexes[(row * (L + 1) + col) * 2] = (row + 1) * L1 + col; } } trianglesPerRow.resize(L); rowIndexes.resize(L); for (row = 0; row < L; ++row) { trianglesPerRow[row] = 2 * L1; rowIndexes[row] = &indexes[row * 2 * L1]; } } |
Once constructed, this data can be rendered directly with vertex arrays:
void Bezier::render() { glVertexPointer(3, GL_FLOAT,sizeof(BSPVertex), &vertex[0].position); glClientActiveTextureARB(GL_TEXTURE0_ARB); glTexCoordPointer(2, GL_FLOAT,sizeof(BSPVertex), &vertex[0].textureCoord); glClientActiveTextureARB(GL_TEXTURE1_ARB); glTexCoordPointer(2, GL_FLOAT, sizeof(BSPVertex), &vertex[0].lightmapCoord); glMultiDrawElementsEXT(GL_TRIANGLE_STRIP, trianglesPerRow.getCArray(), GL_UNSIGNED_INT, (const void **)(rowIndexes.getCArray()), patch.level); } |
Copyright © 2003 Morgan McGuire. All rights reserved. | morgan@cs.brown.edu |
Acknowledgements
Kris Taeleman answered a question while I was working on this document, and I used the following resources:
Max McGuire's Quake 2 BSP File Format,
Kekoa Proudfoot's
Unofficial Quake 3 Map Specs,
Ben "Digiben" Humphrey's Unofficial Quake 3 BSP Format,
Nathan Ostgard's Quake 3 BSP Collision Detection,
Leonardo Boselli's Apocalyx source code,
Paul Baker's Octagon source code,
The Aside Software Titan source code,
The Q3Radient Manual
Keywords: quake 3 quake3 q3 arena quake3arena q3arena map bsp file render opengl spec specs format vertex order