rasterpine

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