2021-06-12

An OpenGL Project: 3D Scene Rendering

An interactive virtual 3D scene demonstration program was developed with the following code structure. Download here

source
├── bmp.cpp          # Reads BMP bitmaps, modified from getBMP.cpp
├── bmp.h            # Defines BMP structure and its methods
├── build.sh         # Compilation + execution script for macOS, not usable on Windows
├── main.cpp         # Main program
├── model            # OBJ model files
│   ├── cat.obj
│   ├── duck.obj
│   ├── github.obj
│   ├── ground.obj
│   ├── lamp.obj
│   └── tiger.obj
├── model.cpp        # Reads and renders models
├── model.h          # Defines Model structure and its methods
├── player.cpp       # Moves player, calculates player coordinates and direction vectors
├── player.h
├── texture          # BMP texture files
│   ├── env          # Skybox environment textures
│   │   ├── negx.bmp
│   │   ├── negy.bmp
│   │   ├── negz.bmp
│   │   ├── posx.bmp
│   │   ├── posy.bmp
│   │   └── posz.bmp
│   ├── ground.bmp
│   └── tiger.bmp
├── utils.cpp        # General functions: initialization, sky rendering, bounding box calculation, OBJ file loading, etc.
└── utils.h

1 Model Loading

Except for loading multiple models, reading texture coordinates, and binding textures, the content in this section is basically the same as the previous experiment and is re-explained for completeness.

To load multiple models, a Model class is defined in model.h, where the vectors f, v, vt, and vn store faces, vertices, texture coordinates, and normal vectors respectively. A new model can be created through the Model constructor. A global variable models is defined to store all models in the scene. Models are loaded and added to models using the following code:

Model *model;
model = new Model(string filename, double x, double y, double z, double p, double q, double r, double scale, float *ka, float *kd, float *ks, float a, bool flat);
model->bindTexture(string filename, unsigned int *texture, int tid);;
models.push_back(model);

The Model constructor executes the following three steps (1.1-1.3) in sequence, followed by texture binding in 1.4.

1.1 Model Reading

The loadOBJ function from the sample code OBJModelViewer.cpp was modified and placed in utils.cpp. This function stores vertex coordinates read from OBJ files in the v vector in sequence, texture coordinates in the vt vector, and vertex indices of triangular faces in the f vector in sequence. Non-triangular faces are split into triangular faces following the triangle fan pattern.

Since the original loadOBJ function did not support reading texture coordinates, it was necessary to add judgment for vt in the original function. Here, all textures are assumed to be 2D textures, so two consecutive coordinates are read and stored in the vt vector:

else if (line.substr(0, 3) == "vt ")
{
    istringstream str(line.substr(3));
    for (i = 1; i <= 2; i++)
    {
        str >> val;
        vt.push_back(val);
    }
}

1.2 Bounding Box Calculation

By iterating through all vertices to obtain the maximum and minimum values of the three coordinates, we get the coordinates of the two vertices on the diagonal of the bounding box (x_{max}, y_{max},z_{max}) and (x_{min},y_{min},z_{min}). The midpoint of these two coordinates is the center of the bounding box. Next, calculate the midpoint and diagonal length of the bounding box:

l = \sqrt{(x_{max}-x_{min})^2+(y_{max}-y_{min})^2+(z_{max}-z_{min})^2}

Finally, adjust the model coordinates by translating the model center to the origin (subtracting the center coordinates from all vertices) and “normalizing” the model by dividing all coordinates by the diagonal length mentioned above, scaling it so that the farthest point is no more than 1 unit away from the origin.

1.3 Normal Vector Calculation

If the three vertices of a triangular face are given in the order specified in the OBJ file as p_0(x_0,y_0,z_0), p_1(x_1,y_1,z_1), and p_2(x_2,y_2,z_2), then a normal vector of this triangular face following the right-hand rule is:

\overrightarrow{p_0p_1}\times\overrightarrow{p_0p_2}=\left|\begin{array}{ccc} \boldsymbol{i} & \boldsymbol{j} & \boldsymbol{k} \\ x_1-x_0 & y_1-y_0 & z_1-z_0 \\ x_2-x_0 & y_2-y_0 & z_2-z_0 \\ \end{array}\right|=\left[\begin{array}{c} (y_1-y_0)(z_2-z_0)-(y_2-y_0)(z_1-z_0)\\ (x_2-x_0)(z_1-z_0)-(x_1-x_0)(z_2-z_0)\\ (x_1-x_0)(y_2-y_0)-(x_2-x_0)(y_1-y_0)\end{array}\right]

Since this normal vector reflects the area of the triangle, vertex normals can be calculated by simply averaging the normals of adjacent faces without area weighting.

Depending on whether flat shading or smooth shading is used for rendering, different normal vectors are assigned to the final vn vector. For flat shading, the normalized normal vector of each triangular face is assigned as the vn value for the first vertex of that face. For smooth shading, the normalized weighted vertex normal vector is assigned as the vn value for the first vertex of that face:

// flat shading
if (flat)
    for (int i = 0; i < f.size(); i += 3)
    {
        vn[3 * f[i]] = fnArr[i];
        vn[3 * f[i] + 1] = fnArr[i + 1];
        vn[3 * f[i] + 2] = fnArr[i + 2];
    }
// smooth shading
else
    for (int i = 0; i < v.size(); i++)
        vn[i] = vnArr[i];

1.4 Texture Binding

The sample code getBMP.cpp was modified to bmp.cpp, and a BMP texture structure was defined in bmp.h. Models are read through the BMP structure initialization function instead of the getBMP function.

The texture binding method for the Model structure is defined in model.cpp, where bmp is a pointer to the BMP structure type within this structure. After passing in the initialized texture array and the corresponding texture index, the BMP file is read via new BMP, and the BMP texture is bound to the corresponding texture using glTexImage2D. For texture scaling, linear interpolation (which produces better results) is used instead of nearest-neighbor interpolation, and texture coordinates outside the original range are filled using the repeat mode:

// bind bitmap texture from file
void Model::bindTexture(string filename, unsigned int *texture, int tid)
{
    this->tid = tid;
    bmp = new BMP(filename);
    glBindTexture(GL_TEXTURE_2D, texture[tid]);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bmp->w, bmp->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, bmp->data);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
}

For models without textures, color material is enabled in advance:

glEnable(GL_COLOR_MATERIAL);
glColorMaterial(GL_FRONT, GL_AMBIENT_AND_DIFFUSE);

Subsequently, the above method is overloaded to implement model color settings, where the structural properties tr, tg, and tb represent the material color of the model:

// bind color texture
void Model::bindTexture(double tr, double tg, double tb)
{
    this->tr = tr;
    this->tg = tg;
    this->tb = tb;
}

2 Model Rendering

The render function for the Model structure is defined in model.cpp to render models.

2.1 Translation and Rotation

The Model structure also includes x, y, z, p, q, r, which represent the position of the model and the rotation angles around the three axes respectively. The model should first be rotated at the origin and then translated to the corresponding position; rotating after translation would cause incorrect positioning. Therefore, the following transformations are first applied in the render function:

glPushMatrix();
glTranslated(x, y, z);
glRotated(r, 0, 0, 1);
glRotated(q, 0, 1, 0);
glRotated(p, 1, 0, 0);

2.2 Texture Rendering

Subsequently, for models with texture mapping, the corresponding texture is enabled based on the texture ID tid, and the blending mode is set to GL_MODULATE to multiply the texture color with the lighting effect color, preparing for subsequent lighting calculations (without this step, only the texture color would be displayed without lighting effects). Then, texture coordinates from the vt vector are specified using glTexCoord2d for rendering:

glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
glBindTexture(GL_TEXTURE_2D, texture[tid]);
glBegin(GL_TRIANGLES);
for (int i : f)
{
    glTexCoord2d(vt[2 * i], vt[2 * i + 1]);
    glNormal3d(vn[3 * i], vn[3 * i + 1], vn[3 * i + 2]);
    glVertex3d(v[3 * i] * scale, v[3 * i + 1] * scale, v[3 * i + 2] * scale);
}
glEnd();
glPopMatrix();

For models without textures, the above glTexEnvf, glBindTexture, and glTexCoord2d functions are not required; only the preset color needs to be specified using glColor before rendering. To determine if a model has textures, check the length of the vt vector—if the length is 0, the model has no texture information.

3 Environment Rendering

3.1 Sky

Referring to the sample code SkyBox.cpp, the textures from this sample are used. First, initialize the Skybox texture in the init function of utils.cpp with texture ID 0. Then, set a global variable env to store the six face textures of the Skybox and bind them sequentially:

// set environment skybox
env[0] = new BMP("texture/env/posx.bmp");
env[1] = new BMP("texture/env/negx.bmp");
env[2] = new BMP("texture/env/posy.bmp");
env[3] = new BMP("texture/env/negy.bmp");
env[4] = new BMP("texture/env/posz.bmp");
env[5] = new BMP("texture/env/negz.bmp");
glBindTexture(GL_TEXTURE_CUBE_MAP, texture[0]);
for (int i = 0; i < 6; i++)
{
    int target = GL_TEXTURE_CUBE_MAP_POSITIVE_X + i;
    glTexImage2D(target, 0, GL_RGBA, env[i]->w, env[i]->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, env[i]->data);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

After initialization, write the renderEnvironment function, which uses this texture to render six square faces sequentially using GL_POLYGON. Before rendering the Skybox, enable the texture and set the blending mode to replace to prevent the Skybox from being affected by lighting:

glEnable(GL_TEXTURE_CUBE_MAP);
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);

3.2 Ground

The grass texture from the reference code FieldAndSky.cpp is used for the ground. To reuse the Model class, a custom OBJ file ground.obj is manually created to represent a rectangular plane with texture coordinates:

v 0 0 0
v 0 0 1
v 1 0 1
v 1 0 0
vt 0 0
vt 0 1
vt 1 1  
vt 1 0
f 1 2 3
f 1 3 4

Subsequently, the ground model can be loaded using the aforementioned model loading and texture binding methods. Its size (side length of the rectangle) is set to 20, and it is translated downward by 1.5 units. Due to its simple structure, the last parameter is set to true (using flat shading). Since grass is not a metallic material, its Shininess value (alpha value in the lighting model) is set to 0.5, with default material parameters used for the rest:

model = new Model("model/ground.obj", 0, -1.5, 0, 0, 0, 0, 20, ka, kd, ks, 0.5, true);
model->bindTexture("texture/ground.bmp", texture, 1);
models.push_back(model);

4 Shadow Rendering

Referring to the sample code BallAndTorusShadowMapped.cpp, shadows are rendered using the Shadow Mapping technique. This method works by generating a depth map from the light’s perspective, then transforming points from the camera’s perspective to the light’s perspective and comparing their depth values. If a point from the camera’s perspective has a greater depth value than the corresponding value in the depth map, it is in shadow.

The original rendering steps were:

  • Create depth map
  • Enable only global ambient light and render all scene content
  • Enable all light sources and render non-shadowed parts of the scene

However, the sample code uses texture coordinate transformation in the third step, which would overwrite the calculated depth map texture if the model has textures in this step, resulting in incorrect texture coordinates. Therefore, the sample code only supports rendering without textures.

To resolve this issue, the rendering steps are modified to:

  • Create depth map
  • Enable all light sources and render all scene content
  • Disable all light sources and directly render shadowed areas in dark gray

In the final rendering, shadowed areas are displayed in dark gray without multiplying with the original texture color under the shadow.

4.1 Depth Map Creation

First, create a depth map from the light’s perspective. This requires knowing the light’s projection matrix and view matrix. The depth map size is set to 1024\times1024, so the aspect ratio of the light’s projection matrix is 1:1, and the far plane of the viewing frustum is set to twice the world size (in extreme cases, the diagonal distance of the Skybox is \sqrt{3} times the world size). Since it is a point light source, FOV cannot be set directly, but the light source is treated as the sun positioned high above the scene, so only downward-emitting rays need to be considered with a large FOV (set to 120 as LIGHTFOV here). The projection matrix is thus obtained as:

gluPerspective(LIGHTFOV, 1, 1, WORLDSIZE * 2);

For the view matrix, the light source is assumed to always point toward the origin. Using lpos to represent the light source position, the light’s view matrix is:

gluLookAt(lpos[0], lpos[1], lpos[2], 0, 0, 0, 0, 1, 0);

Simultaneously, set the viewport size to the depth map size SHADOWSIZE (1024 here):

glViewport(0, 0, SHADOWSIZE, SHADOWSIZE);

After setting the above matrices, disable color output and cull the front faces of models from the light’s perspective (only back faces are needed to determine the depth map). Then, render models without textures to save time, capture depth information from the light’s perspective, and store it in the last texture:

glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);
glCullFace(GL_FRONT);
// draw the scene without texture, capture the depth buffer
for (Model *model : models)
  model->render();
glBindTexture(GL_TEXTURE_2D, texture[MAXTID - 1]);
glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, 0, 0, SHADOWSIZE, SHADOWSIZE, 0);

This yields the depth map texture.

4.2 Scene Rendering

Subsequently, clear the depth buffer and render the scene from the camera’s perspective. The camera’s projection matrix is:

gluPerspective(FOV, (double)w / (double)h, 1, WORLDSIZE * 2);

where the aspect ratio is that of the window, and FOV is set to 60. The camera’s view matrix is:

gluLookAt(ppos[0], ppos[1], ppos[2], ppos[0] + pvec[0], ppos[1] + pvec[1], ppos[2] + pvec[2], 0, 1, 0);

where ppos is the player’s coordinates and pvec is the direction vector the player is facing. Set the viewport to match the window size:

glViewport(0, 0, w, h);

After setting the above matrices, enable color output, cull back faces of models from the camera’s perspective to save time, enable lighting, and render the environment (Skybox) and models sequentially:

glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glCullFace(GL_BACK);
// draw the scene
glEnable(GL_LIGHTING);
if (renderLight)
{
    glEnable(GL_LIGHT0);
    glLightfv(GL_LIGHT0, GL_POSITION, lpos);
}
renderEnvironment(texture);
for (Model *model : models)
    model->render(texture);

4.3 Shadow Area Rendering

To compare the depth of a point \boldsymbol{p}_C in camera coordinates with the corresponding point \boldsymbol{p}_L in the depth map from the light’s perspective, coordinate transformation is required. A point (x_C,y_C,z_C) in camera coordinates must undergo the inverse camera view transformation \boldsymbol{V}_C^{-1} to obtain coordinates in the canonical coordinate system, then the light view transformation \boldsymbol{V}_L to obtain coordinates from the light’s perspective, and finally the light projection matrix \boldsymbol{P}_L to get the corresponding coordinates on the depth map. However, the projected depth values range between [-1,1], so to map to the depth map range [0, 1], scale by half and translate by half a unit (implemented via the offset matrix \boldsymbol{B}). The final result is:

\boldsymbol{p}_L=\boldsymbol{B}\boldsymbol{P}_L\boldsymbol{V}_L\boldsymbol{V}_C^{-1}\boldsymbol{p}_C

The offset matrix \boldsymbol{B} can be implemented as:

glTranslated(0.5, 0.5, 0.5);
glScaled(0.5, 0.5, 0.5);

The light projection matrix and light view matrix (as defined above) are directly multiplied below:

gluPerspective(LIGHTFOV, 1, 1, WORLDSIZE * 2);          // light Projection
gluLookAt(lpos[0], lpos[1], lpos[2], 0, 0, 0, 0, 1, 0); // light Modelview

Subsequently, to apply the inverse camera view transformation, extract the product of the three matrices on the left:

\boldsymbol{T}=\boldsymbol{B}\boldsymbol{P}_L\boldsymbol{V}_L\boldsymbol{V}_C^{-1}

Referring to the sample code, implement the inverse transformation via texture coordinate generation:

double trans[16];
glGetDoublev(GL_MODELVIEW_MATRIX, trans);
glPopMatrix();
// generate texture coordinates
double tr0[] = {trans[0], trans[4], trans[8], trans[12]};
double tr1[] = {trans[1], trans[5], trans[9], trans[13]};
double tr2[] = {trans[2], trans[6], trans[10], trans[14]};
double tr3[] = {trans[3], trans[7], trans[11], trans[15]};
glEnable(GL_TEXTURE_GEN_S);
glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGendv(GL_S, GL_EYE_PLANE, tr0);
glEnable(GL_TEXTURE_GEN_T);
glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGendv(GL_T, GL_EYE_PLANE, tr1);
glEnable(GL_TEXTURE_GEN_R);
glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGendv(GL_R, GL_EYE_PLANE, tr2);
glEnable(GL_TEXTURE_GEN_Q);
glTexGeni(GL_Q, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGendv(GL_Q, GL_EYE_PLANE, tr3);

Here, GL_TEXTURE_GEN_* sets the texture coordinate * to the dot product of tr* and the original coordinates of the current point:

S=\boldsymbol{t}_1\boldsymbol{p}\\ T=\boldsymbol{t}_2\boldsymbol{p}\\ R=\boldsymbol{t}_3\boldsymbol{p}\\ Q=\boldsymbol{t}_4\boldsymbol{p}\\

where \boldsymbol{t}_i represents row-wise partitioning of the transformation matrix \boldsymbol{T}:

\boldsymbol{T}=\left[\begin{array}{c}\boldsymbol{t}_1\\\boldsymbol{t}_2\\\boldsymbol{t}_3\\\boldsymbol{t}_4\end{array}\right]

\boldsymbol{p} is the original coordinate. Since the current view matrix during code execution is the camera’s view matrix, the view coordinates corresponding to \boldsymbol{p} are the camera view coordinates \boldsymbol{p}_C:

\boldsymbol{p}=\boldsymbol{V}_C^{-1}\boldsymbol{p}_C

Thus:

\left[\begin{array}{c}S\\T\\R\\Q\end{array}\right]=\boldsymbol{Tp}=\boldsymbol{TV}_C^{-1}\boldsymbol{p}_C=\boldsymbol{B}\boldsymbol{P}_L\boldsymbol{V}_L\boldsymbol{V}_C^{-1}\boldsymbol{p}_C=\boldsymbol{p}_L

Therefore, the R value corresponds to the transformed depth information to be compared. Set a comparison function to set the alpha value to 0 if the transformed depth (R) is greater than the original depth map value, otherwise to 1. To render shadowed areas, discard parts with alpha value 1 by enabling GL_ALPHA_TEST and setting the comparison function to a threshold of less than 0.5:

// activate shadow map, set shadow comparison to generate an alpha value
glBindTexture(GL_TEXTURE_2D, texture[MAXTID - 1]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_R_TO_TEXTURE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);
glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_TEXTURE_MODE, GL_ALPHA);
// 0 for failed comparisons, 1 for successful, discard 1
glEnable(GL_ALPHA_TEST);
glAlphaFunc(GL_LESS, 0.5);

Finally, draw the shadow areas:

// draw shadow area
if (renderShadow)
    for (Model *model : models)
        model->renderShadow();

A renderShadow method is implemented for the Model structure here. During rendering, textures are disabled, and the rendering color is set to dark gray via:

// draw with black to create shadow effect
glColor3d(0.1, 0.1, 0.1);

This achieves the shadow effect.

5 Interactive Controls

5.1 Player Movement

Use glutKeyboardFunc to set the input callback function to input1. In this function, the player’s forward/backward/left/right movement is controlled via W/A/S/D keys, and upward/downward flight is controlled via Space/C keys. Two global variables ppos and pvec are maintained to track the player’s position and facing direction. Relevant player movement functions are implemented in player.cpp.

Up/down movement is the simplest: regardless of the player’s facing direction, only the y-value needs to be changed (consistent with jump/fly mechanics in games like PUBG). If \boldsymbol{m} represents the unit direction vector of movement and \boldsymbol{p} represents the player’s direction vector, then:

\boldsymbol{m}=\left[\begin{array}{c}0\\1\\0\\\end{array}\right]

Code for upward movement (where d is the movement distance) is shown below. For downward movement, simply set d to a negative value (the same applies to other movement types):

// move player up (i.e. fly)
void moveUp(double *ppos, double *pvec, double d)
{
    ppos[1] += d;
}

Forward movement uses the projection of the player’s direction vector onto the x-z plane as the movement direction (pressing W while looking up only moves horizontally without upward flight, consistent with PUBG mechanics). Therefore, discard the y-component, normalize the x-z projection, and multiply by d:

\boldsymbol{m}=\left[\begin{array}{c}p_x\\0\\p_z\\\end{array}\right]/\sqrt{p_x^2+p_z^2}

Add the result to the player’s x and z coordinates sequentially:

// move player go front
void moveFront(double *ppos, double *pvec, double d)
{
    double len = sqrt(pvec[0] * pvec[0] + pvec[2] * pvec[2]);
    ppos[0] += pvec[0] / len * d;
    ppos[2] += pvec[2] / len * d;
}

Left/right movement is slightly more complex: rotate the x-z projection 90 degrees clockwise (not counterclockwise, since the z-axis points outward and x-axis to the right) and add to the player’s coordinates:

\boldsymbol{m}=\left[\begin{array}{ccc}0&0&1\\0&1&0\\-1&0&0\end{array}\right]\left[\begin{array}{c}p_x\\0\\p_z\\\end{array}\right]/\sqrt{p_x^2+p_z^2}=\left[\begin{array}{c}p_z\\0\\-p_x\\\end{array}\right]/\sqrt{p_x^2+p_z^2}

Code implementation:

// move player left
void moveLeft(double *ppos, double *pvec, double d)
{
    double len = sqrt(pvec[0] * pvec[0] + pvec[2] * pvec[2]);
    ppos[0] += pvec[2] / len * d;
    ppos[2] -= pvec[0] / len * d;
}

5.2 Player View Rotation

Use glutSpecialFunc to set the input callback function to input2. In this function, left/right arrow keys control horizontal view rotation, and up/down arrow keys control vertical view rotation. Rotation around the player’s z-axis (tilting the view) is not implemented (consistent with games like PUBG).

Horizontal rotation simply rotates the player’s direction vector clockwise around the y-axis (not counterclockwise, as explained earlier). If \boldsymbol{p}' represents the rotated direction vector:

\boldsymbol{p'}=\left[\begin{array}{ccc}\cos\alpha&0&\sin\alpha\\0&1&0\\-\sin\alpha&0&\cos\alpha\end{array}\right]\left[\begin{array}{c}p_x\\p_y\\p_z\\\end{array}\right]=\left[\begin{array}{c}p_x\cos\alpha+p_z\sin\alpha\\p_y\\-p_x\sin\alpha+p_z\cos\alpha\\\end{array}\right]

Code implementation (set a to negative for right rotation):

// rotate player's head left
void rotLeft(double *pvec, double a)
{
    double x = pvec[0] * cos(a) + pvec[2] * sin(a);
    double z = -pvec[0] * sin(a) + pvec[2] * cos(a);
    pvec[0] = x;
    pvec[2] = z;
}

Vertical rotation is the most complex, as the rotation axis is the player’s local x-axis (not the global x-axis). The direction vector is first rotated counterclockwise (not clockwise) by \theta around the y-axis, then clockwise (not counterclockwise) by the desired angle around the x-axis, and finally rotated back by \theta, as shown in the figure below:

Thus:

\boldsymbol{p'}= \left[\begin{array}{ccc}\cos\theta&0&-\sin\theta\\0&1&0\\\sin\theta&0&\cos\theta\end{array}\right] \left[\begin{array}{ccc}1&0&0\\0&\cos\alpha&\sin\alpha\\0&-\sin\alpha&\cos\alpha\end{array}\right] \left[\begin{array}{ccc}\cos\theta&0&\sin\theta\\0&1&0\\-\sin\theta&0&\cos\theta\end{array}\right] \left[\begin{array}{c}p_x\\p_y\\p_z\\\end{array}\right]

where:

\theta=\arctan\frac{p_x}{p_z}

Using the rotLeft function, the code is implemented as:

// rotate player's head up
void rotUp(double *pvec, double a)
{
    if ((pvec[1] > 0.99 && a > 0) || (pvec[1] < -0.99 && a < 0))
        return;
    double theta = atan2(pvec[0], pvec[2]);
    rotLeft(pvec, -theta);
    double y = pvec[1] * cos(a) + pvec[2] * sin(a);
    double z = -pvec[1] * sin(a) + pvec[2] * cos(a);
    pvec[1] = y;
    pvec[2] = z;
    rotLeft(pvec, theta);
}

This code limits vertical rotation to prevent the player from looking beyond the ceiling/floor—if the y-component of the unit vector approaches 1 or -1, further upward/downward rotation is prohibited.

5.3 Light Source and Object Movement

The controlType variable controls whether the player, light source, or objects are moved (modifiable via the menu). For light source/object movement, directly modify lpos or the model’s x/y/z coordinates using W/A/S/D/C/Space keys (without considering relative position to the player). For demonstration purposes, only the second model in the scene is moved.

5.4 Light Color and Shadows

The setLightColor function is implemented in utils.cpp to change light color values. Only ambient and diffuse light are modified (ambient light intensity is reduced to 0.3), with specular light remaining white:

// change light color (ambient and diffuse, specular is always white)
void setLightColor(double r, double g, double b)
{
    la[0] = 0.3 * r;
    la[1] = 0.3 * g;
    la[2] = 0.3 * b;
    ld[0] = r;
    ld[1] = g;
    ld[2] = b;
    glLightfv(GL_LIGHT0, GL_AMBIENT, la);
    glLightfv(GL_LIGHT0, GL_DIFFUSE, ld);
    glLightfv(GL_LIGHT0, GL_SPECULAR, ls);
}

Additionally, the renderLight and renderShadow flags can be modified via the menu:

  • renderLight: Determines whether lighting is rendered (only global ambient light if disabled)
  • renderShadow: Determines whether shadows are rendered (disables Section 4.3 code if disabled)

After modification, glutPostRedisplay is called to apply changes immediately.

5.5 Light Animation

The animate callback function is bound via glutTimerFunc with a 50ms interval.

In the animation, the light source is translated left to right to simulate sunrise/sunset. If the x-coordinate exceeds 10, it moves back. The global animation variable (modifiable via the menu) controls whether the animation runs:

// animation callback
void animate(int entry)
{
    if (animation)
    {
        lpos[0] += 0.3;
        if (lpos[0] > 10)
            lpos[0] = -10;
        glutPostRedisplay();
    }
    glutTimerFunc(50, animate, 1);
}

6 Quality Improvement

Referring to the sample code antiAliasing+Multisampling.cpp, supersampling and anti-aliasing are implemented.

6.1 Supersampling

Supersampling is enabled via:

glEnable(GL_MULTISAMPLE);

With additional GLUT_MULTISAMPLE initialization:

glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH | GLUT_MULTISAMPLE);

Enabling supersampling reduces frame rate slightly.

6.2 Anti-Aliasing

Anti-aliasing is achieved by enabling smooth rendering for points, lines, and polygons, with blending functions set as per the sample:

glEnable(GL_POLYGON_SMOOTH);
glEnable(GL_LINE_SMOOTH);
glEnable(GL_POINT_SMOOTH);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

Enabling anti-aliasing also reduces frame rate slightly.

6.3 Shadow Quality Improvement

By default, banding artifacts may appear in non-shadowed areas due to floating-point calculation errors causing some points to be incorrectly judged as in shadow (depth values matching exactly with the depth map). This issue is resolved by modifying the offset matrix \boldsymbol{B}:

glTranslated(0.5, 0.5, 0.4997);

This introduces a small threshold for depth comparison to prevent shadow banding caused by precision errors. The value 0.4997 was empirically determined as optimal for this scene (too small a value causes shadow discontinuities).

Additionally, shadow aliasing can be reduced by increasing the Shadow Map size from 512\times512 (sample code) to 1024\times1024.

7 Results

7.1 Compilation

On macOS, install glew and glut libraries via Homebrew:

brew install glew
brew install glut

Then execute ./build.sh to compile. For manual compilation:

c++ utils.cpp bmp.cpp model.cpp player.cpp main.cpp -lglew -framework glut -framework opengl

Link these libraries with the system-provided OpenGL framework to complete compilation.

The code was only tested on macOS but includes basic compatibility for Windows and Linux. It should compile successfully on Windows. If compilation fails or results differ from the screenshots below, compile and run on macOS.

After compilation, run:

./a.out

to launch the window interface:

The window size is 800\times600, with 6 loaded models:

  • Grass: Uses texture from sample code, flat shading, Shininess = 0.5
  • GitHub contribution statistics: 3D visualization of contributions in 2020 from my GitHub account, smooth shading, Shininess = 20
  • Tiger: Uses provided texture, smooth shading, Shininess = 10
  • Duck/Cat: Uses color materials, smooth shading, Shininess = 10/5 respectively
  • Street lamp: Uses model from previous experiment, flat shading, Shininess = 10

All model parameters can be viewed in utils.cpp:

// load models
Model *model;
model = new Model("model/ground.obj", 0, -1.5, 0, 0, 0, 0, 20, ka, kd, ks, 0.5, true);
model->bindTexture("texture/ground.bmp", texture, 1);
models.push_back(model);
model = new Model("model/github.obj", 0, -0.4, 4, 270, 0, 0, 10, ka, kd, ks, 20, false);
model->bindTexture(0.5, 0.5, 0.5);
models.push_back(model);
model = new Model("model/tiger.obj", 0, -0.6, 0, 270, 90, 0, 5, ka, kd, ks, 10, false);
model->bindTexture("texture/tiger.bmp", texture, 2);
models.push_back(model);
model = new Model("model/cat.obj", 3.5, -0.5, 1, 270, 90, 0, 3, ka, kd, ks, 5, false);
model->bindTexture(0.8, 0.4, 0.1);
models.push_back(model);
model = new Model("model/duck.obj", -3.5, -0.4, 4, 270, 180, 0, 3, ka, kd, ks, 10, false);
model->bindTexture(0.8, 0.6, 0);
models.push_back(model);
model = new Model("model/lamp.obj", -4, 0, 0.5, 0, 0, 0, 4, ka, kd, ks, 10, true);
model->bindTexture(0.1, 0.6, 0.9);
models.push_back(model);

Default material parameters ka, kd, ks are:

float ka[4] = {1, 1, 1, 1};
float kd[4] = {0.6, 0.6, 0.6, 1};
float ks[4] = {0.4, 0.4, 0.4, 1};

7.2 Movement

The player can move freely and rotate the view using the methods described in Sections 5.1 and 5.2:

Settings can also be changed via the menu to move the light source and models:

7.3 Lighting and Shadows

Lighting and shadows can be disabled:

Light color can be changed (shadow disabled, red with slight blue component):

Shadows enabled with green light (not pure green):

7.5 Animation

Animation can be started/stopped via the menu (light source movement demonstrated above was performed with animation disabled).

8 Summary

This course project covers:

  • Model loading (including custom models)
  • BMP texture loading
  • Environment rendering using Skybox
  • Model, lighting, and texture rendering
  • Shadow rendering using Shadow Mapping
  • Player movement/rotation control via matrix calculations
  • Quality improvement via anti-aliasing and supersampling
  • Two methods for improving shadow quality
  • Implementation of light source animation and color modification
  • Enabling/disabling light sources and shadows

Limitations include:

  • Low frame rate due to basic rendering methods
  • Relatively poor shadow quality
  • Low-resolution model textures (due to time constraints) and unappealing appearance of textureless models