2021-06-12An 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