Devlog 03 · writeup
Your first triangle in LWJGL 3
A triangle is a boring result and an unreasonable amount of work. That's the point of it: by the time one appears, you have a window, a context, a buffer on the GPU, a vertex layout, a compiled shader program and a render loop. Everything after this is variation.
This is the whole thing in one file. Nothing is hidden behind a Renderer
class we'll write later.
The dependencies
LWJGL ships one jar per module plus a natives jar per platform. Take the BOM so the versions stay in step, and pull the natives classifier for whatever you're building on — the generator at lwjgl.org/customize will print the exact block for your setup.
dependencies {
implementation platform("org.lwjgl:lwjgl-bom:3.3.3")
implementation "org.lwjgl:lwjgl"
implementation "org.lwjgl:lwjgl-glfw"
implementation "org.lwjgl:lwjgl-opengl"
runtimeOnly "org.lwjgl:lwjgl::natives-macos-arm64"
runtimeOnly "org.lwjgl:lwjgl-glfw::natives-macos-arm64"
runtimeOnly "org.lwjgl:lwjgl-opengl::natives-macos-arm64"
}
On macOS the JVM also needs -XstartOnFirstThread, or GLFW will fail to
create a window and tell you very little about why.
A window and a context
GLFW gives us the window and the OpenGL context; it does not give us OpenGL. That's the
part people miss: GL.createCapabilities() is what binds the function
pointers for the current context on the current thread. Forget it and
every GL call throws.
import org.lwjgl.glfw.GLFWErrorCallback;
import org.lwjgl.opengl.GL;
import static org.lwjgl.glfw.GLFW.*;
import static org.lwjgl.opengl.GL33.*;
import static org.lwjgl.system.MemoryUtil.NULL;
public class Triangle {
private long window;
private void init() {
GLFWErrorCallback.createPrint(System.err).set();
if (!glfwInit()) {
throw new IllegalStateException("GLFW failed to initialise");
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); // required on macOS
glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE);
window = glfwCreateWindow(1280, 720, "Raster Pine", NULL, NULL);
if (window == NULL) {
throw new RuntimeException("Failed to create the window");
}
glfwMakeContextCurrent(window);
glfwSwapInterval(1); // vsync
GL.createCapabilities(); // without this, nothing below works
glfwSetFramebufferSizeCallback(window, (win, w, h) -> glViewport(0, 0, w, h));
glfwShowWindow(window);
}
}
Vertices the GPU can see
Three positions in clip space, straight into a buffer object. The VAO is the part worth slowing down for: it does not hold the data, it holds the description of the data — which buffer, how many components, what stride, what offset. Bind the VAO later and the GPU knows how to read the vertices again.
private int vao;
private void createMesh() {
float[] vertices = {
// x y z
0.0f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f
};
vao = glGenVertexArrays();
glBindVertexArray(vao);
int vbo = glGenBuffers();
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, false, 3 * Float.BYTES, 0L);
glEnableVertexAttribArray(0);
glBindVertexArray(0);
}
The 0 in glVertexAttribPointer is the attribute location, and
it has to match the layout(location = 0) in the vertex shader. That number
is the entire contract between your Java code and your GLSL.
Two shaders and a program
private static final String VERTEX_SRC = """
#version 330 core
layout(location = 0) in vec3 aPos;
void main() {
gl_Position = vec4(aPos, 1.0);
}
""";
private static final String FRAGMENT_SRC = """
#version 330 core
out vec4 fragColor;
void main() {
fragColor = vec4(0.09, 0.68, 0.48, 1.0);
}
""";
private static int compile(int type, String source) {
int shader = glCreateShader(type);
glShaderSource(shader, source);
glCompileShader(shader);
if (glGetShaderi(shader, GL_COMPILE_STATUS) == GL_FALSE) {
throw new RuntimeException(glGetShaderInfoLog(shader));
}
return shader;
}
private int createProgram() {
int vs = compile(GL_VERTEX_SHADER, VERTEX_SRC);
int fs = compile(GL_FRAGMENT_SHADER, FRAGMENT_SRC);
int program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
if (glGetProgrami(program, GL_LINK_STATUS) == GL_FALSE) {
throw new RuntimeException(glGetProgramInfoLog(program));
}
glDetachShader(program, vs);
glDetachShader(program, fs);
glDeleteShader(vs);
glDeleteShader(fs);
return program;
}
Check the compile and link status every single time. A shader that failed to compile does not throw, does not warn, and does not draw — you get a black window and no clue, and you lose an evening to it. Ask me how I know.
The loop
private void loop(int program) {
glClearColor(0.95f, 0.96f, 0.94f, 1.0f);
while (!glfwWindowShouldClose(window)) {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(program);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
glfwSwapBuffers(window);
glfwPollEvents();
}
}
That's a triangle. Run it and you get a green wedge on an off-white field, which is either the most satisfying or the most anticlimactic result in graphics programming, depending on how long it took you.
When it doesn't work
- Black window, no error. Almost always a shader that failed silently, or a VAO that isn't bound at draw time.
- Crash on
glfwCreateWindowon macOS. Missing-XstartOnFirstThread. NullPointerExceptiondeep in an LWJGL call.GL.createCapabilities()wasn't called on this thread, or the context isn't current.- Nothing draws, but no error anywhere. Check winding and
glDisable(GL_CULL_FACE)— a back-facing triangle is invisible and blameless.