Leaves One

Alan Richard's Blog

Use LWJGL 3 with ThinMatrix's OpenGL 3D Game Tutorial (Part 2)

What is this?

This is a series of notes about the ThinMatrix’s OpenGL 3D Game Tutorial. Here I concentrate on changes I made to the code to make it work with the latest LWJGL 3.3.1 and Java 17.

In headings, I will include the tutorial episode number in the format of T11, T12, etc. This is to make it easier to find the corresponding episode.

This is the second part of the series. You can find part 1 here.

Modifications to the improved OBJ Loader (T16)

Note: This section is based on Tutorial 16: Fog.

In tutorial 16, an improved OBJFileLoader is introduced. To make it work with the latest LWJGL, we need to make a few changes.

Firstly, replace these import in the OBJFileLoader class:

import org.lwjgl.util.vector.Vector2f;
import org.lwjgl.util.vector.Vector3f;

with

import org.joml.Vector2f;
import org.joml.Vector3f;

I also changed RES_LOC path to src/main/resources/model/ to conform my project structure.

Then replace the following import in the Vertex class:

import org.lwjgl.util.vector.Vector3f;

with

import org.joml.Vector3f;

Now, the new OBJ loader is ready to use. But I made a few more changes to it.

Replace all occurrences of (float) Float.valueOf with Float.parseFloat in the OBJFileLoader class.

For example, the following code:

Vector3f vertex = new Vector3f((float) Float.valueOf(currentLine[1]),
(float) Float.valueOf(currentLine[2]),
(float) Float.valueOf(currentLine[3]));

can be changed to:

Vector3f vertex = new Vector3f(Float.parseFloat(currentLine[1]),
Float.parseFloat(currentLine[2]),
Float.parseFloat(currentLine[3]));

You can also simplify some ArrayList declarations by using diamonds in OBJFileLoader:

Replace

List<Vertex> vertices = new ArrayList<Vertex>();
List<Vector2f> textures = new ArrayList<Vector2f>();
List<Vector3f> normals = new ArrayList<Vector3f>();
List<Integer> indices = new ArrayList<Integer>();

with

List<Vertex> vertices = new ArrayList<>();
List<Vector2f> textures = new ArrayList<>();
List<Vector3f> normals = new ArrayList<>();
List<Integer> indices = new ArrayList<>();

In the tutorial, to load a model, before we apply the OBJFileLoader class, what we need is:

RawModel model = OBJLoader.loadObjModel("model_name", loader);

But now it is:

ModelData treeModelData = OBJFileLoader.loadOBJ("model_name");
RawModel model = loader.loadToVAO(treeModelData.getVertices(), treeModelData.getTextureCoords(),
treeModelData.getNormals(), treeModelData.getIndices());

So I added a reload method to the Loader class:

Loader.java

public RawModel loadToVAO(ModelData modelData) {
return loadToVAO(modelData.getVertices(), modelData.getTextureCoords(), modelData.getNormals(), modelData.getIndices());
}

Then we can use it like this:

RawModel model = loader.loadToVAO(OBJFileLoader.loadOBJ("model_name"));

getCurrentTime in a LGWJL 3 way (T18)

Note: This section is based on Tutorial 18: Player Movement.

In tutorial, the implementation of getCurrentTime method involves Sys.getTime() and Sys.getTimerResolution().

But in LWJGL 3, we can use GLFW.glfwGetTime() to do the same thing according to this discussion (Internet Archive link).

So the getCurrentTime method can be changed to:

public static long getCurrentTime() {
return (long) (GLFW.glfwGetTime() * 1000);
}

Player movement: Fix shaking and simplify (T18)

Note: This section is based on Tutorial 18: Player Movement.

You will notice that in the tutorial, the player will shake when standing still. That is caused by a flaw in the falling check.

shaking

In tutorial, the player will stop falling only if the player’s Y is less than the terrain height. But what we really want is to stop decreasing the player’s Y once the player’s Y is equal to the terrain height.

Player.java

public void move() {
checkInputs();
super.increaseRotation(0, currentTurnSpeed * DisplayManager.getFrameTimeSeconds(), 0);
float distance = currentSpeed * DisplayManager.getFrameTimeSeconds();
float dx = (float) (distance * Math.sin(Math.toRadians(super.getRotY())));
float dz = (float) (distance * Math.cos(Math.toRadians(super.getRotY())));

upwardsSpeed += GRAVITY * DisplayManager.getFrameTimeSeconds();
super.increasePosition(dx, upwardsSpeed * DisplayManager.getFrameTimeSeconds(), dz);

if (super.getPosition().y <= TERRAIN_HEIGHT) { // <-- ❗️ IMPORTANT: use `<=` instead of `<`
upwardsSpeed = 0;
super.getPosition().y = TERRAIN_HEIGHT;
}
}

Moreover, I am not using isInAir flag in the tutorial. Instead, I use upwardsSpeed to check if the player is in the air. So the jumping part of the checkInput method can be simplified to:

Player.java

if (Keyboard.isKeyDown(GLFW_KEY_SPACE)) {
if (upwardsSpeed == 0) {
jump();
}
}

Fix flickering when entity moves

When moving the entity, you will notice that the entity will flicker.

Player flickering

This can be easily fixed by making the following changes to DisplayManager.

First, append the following code right after any glfwWindowHint calls:

glfwWindowHint(GLFW_DOUBLEBUFFER, GLFW_FALSE);

Next, disable v-sync by changing the following code:

// Enable v-sync
glfwSwapInterval(1);

to

glfwSwapInterval(0);    <- ❗️ Here

Finally, add the following line to updateDisplay method, after glfwSwapBuffers:

glFlush();

Flikering fixed

Get the missing Mouse class back (T19)

Note: This section is based on Tutorial 19: 3rd Person Camera.

Just like the Keyboard class (see part 1), the Mouse class is also missing in LWJGL 3. So I wrote a Mouse class to replace it.

Mouse.java

package renderEngine.input;

import renderEngine.DisplayManager;
import org.lwjgl.glfw.GLFWCursorPosCallback;
import org.lwjgl.glfw.GLFWMouseButtonCallback;
import org.lwjgl.glfw.GLFWScrollCallback;

public class Mouse {
private static float mouseX, mouseY, prevMouseX, prevMouseY;
private static boolean leftButtonPressed, rightButtonPressed;
private static float dWheel;

public static void createCallbacks() {
GLFWMouseButtonCallback mouseButtonCallback = new GLFWMouseButtonCallback() {
@Override
public void invoke(long window, int button, int action, int mods) {
leftButtonPressed = button == 0 && action == 1;
rightButtonPressed = button == 1 && action == 1;
}
};
GLFWScrollCallback scrollCallback = new GLFWScrollCallback() {
@Override
public void invoke(long window, double xoffset, double yoffset) {
dWheel = (float) yoffset;
}
};
GLFWCursorPosCallback cursorPosCallback = new GLFWCursorPosCallback() {
@Override
public void invoke(long window, double xpos, double ypos) {
prevMouseX = mouseX;
prevMouseY = mouseY;
mouseX = (float) xpos;
mouseY = (float) (DisplayManager.getWindowHeight() - ypos);
}
};
mouseButtonCallback.set(DisplayManager.getWindow());
scrollCallback.set(DisplayManager.getWindow());
cursorPosCallback.set(DisplayManager.getWindow());
}

public static void update() {
dWheel = 0;
}

public static float getX() {
return mouseX;
}

public static float getY() {
return mouseY;
}

public static float getDX() {
return mouseX - prevMouseX;
}

public static float getDY() {
return mouseY - prevMouseY;
}

public static boolean isLeftButtonPressed() {
return leftButtonPressed;
}

public static boolean isRightButtonPressed() {
return rightButtonPressed;
}

public static float getDWheel() {
return dWheel;
}
}

Then you can use it exactly the same way as in the tutorial.

I found that by using both L Mouse Button and R Mouse Button to control the camera’s pitch and yaw, the camera will be more responsive. So my calculatePitch and calculateAngleAroundPlayer methods are changed to:

Camera.java

private void calculatePitch() {
if (Mouse.isLeftButtonPressed()) {
float pitchChange = Mouse.getDY() * 0.1f;
pitch -= pitchChange;
}
}

private void calculateAngleAroundPlayer() {
if (Mouse.isLeftButtonPressed()) {
float angleChange = Mouse.getDX() * 0.1f;
angleAroundPlayer -= angleChange;
}
}

Camera control

Optimizing multiple light source support (T25)

We can add the following check to terrain and entity’s fragment shader to optimize the rendering performance when there are light sources less than the maximum amount.

terrainFragmentShader.glsl; entityFragmentShader.glsl

void main(void) {
// ...
for (int i=0; i<MAX_LIGHTS; i++) {
if (lightColor[i].x == 0 && lightColor[i].y == 0 && lightColor[i].z == 0) {
continue;
}
// ...
}
}

Or we can add an uniform variable int lightCount to the shader program and use it to limit the loop. By doing so, we can set the lightCount to the actual number of light sources when calling loadLights method.

Note that the MAX_LIGHTS constant is a macro defined at the beginning of shader files.

terrainFragmentShader.glsl; entityFragmentShader.glsl

#version 400 core
#define MAX_LIGHTS 8

By defining MAX_LIGHTS as a macro, we can easily change the allowed number of light sources in the future.

And change the while-loop in the loadShader method of ShaderProgram class to rewrite the MAX_LIGHTS macro to reflect the upper limit of light sources defined in Java code.

ShaderProgram.java

// ...
private static int loadShader(String file, int type) {
// ...
while ((line = reader.readLine()) != null) {
if (line.startsWith("#define MAX_LIGHTS")) {
line = "#define MAX_LIGHTS " + MAX_LIGHTS;
}
shaderSource.append(line).append("\n");
}
// ...
}
// ...

And make sure the static MAX_LIGHTS constant is defined in the MasterRenderer class instead of the StaticShader or TerrainShader class.

ShtaticShader.java

public abstract class ShaderProgram {
protected static final int MAX_LIGHTS = 8
// ...
}

PNGDecoder but without third-party libraries (T27)

I am not using PNGDecoder to load textures. Instead, I am using the following decodeTextureFile method to load textures.

Loader.java

private TextureData decodeTextureFile(String path) {
int width;
int height;
int[] pixels;
try {
BufferedImage image = ImageIO.read(new File(path));
width = image.getWidth();
height = image.getHeight();
pixels = new int[width * height];
image.getRGB(0, 0, width, height, pixels, 0, width);
} catch (IOException e) {
System.err.println("Failed to load texture file: " + path);
e.printStackTrace();
throw new RuntimeException(e);
}
return new TextureData(width, height, pixels);
}

Note that in TextureData class, the pixels field is an array of int instead of ByteBuffer.

And in the loadCubeMap method of Loader class, the glTexImage2D calling does not require any changes.

Loader.java

public int loadCubeMap(String[] textureFiles) {
// ...
for (int i = 0; i < textureFiles.length; i++) {
TextureData data = decodeTextureFile("src/main/resources/texture/" + textureFiles[i] + ".png");
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGBA, data.getWidth(), data.getHeight(),
0, GL_RGBA, GL_UNSIGNED_BYTE, data.getPixels());
}
// ...
}


I’m continuing working on the tutorial and will update this post as I go. Stay tuned!

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.
If you checked “Remember me”, your email address and name will be stored in your browser for your convenience.