I am glad to see you on the pages of Greg’s Journal! In this post, I will show the internals of a small demo coincidentally called “Simple”:) Despite an apparent simplicity, this code can help highlight some of the foundations of my approach to realtime rendering.

Here is what we want to achieve – just a bunch of triangles rotating around a predefined axis:

Simple Demo

And here is the starting point of the demo:

desktop/com.blaster.impl.Simple::window

private val window = object : LwjglWindow(isHoldingCursor = false)

You can navigate to the code by clicking at the header of the code snippet.

In this article

LwjglWindow

I should probably start by explaining the basics. To hide OpenGL boilerplate code for desktop, I created a class called LwjglWindow:

desktop/com.blaster.platform.LwjglWindow

abstract class LwjglWindow(
        private val isHoldingCursor: Boolean = true,
        private var isFullscreen: Boolean = false,
        private val isMultisampled: Boolean = false)

This class handles the os windows, mouse and keyboard input, resizing, etc. I am relying on LWJGL as a backend for OpenGL handling on desktop [1].

Probably the most interesting part of it is the creation of the window:

desktop/com.blaster.platform.LwjglWindow::createWindow

private fun createWindow(): Long {
    val new = if (isFullscreen) {
        glfwCreateWindow(fullWidth, fullHeight, "Blaster!", glfwGetPrimaryMonitor(), window)
    } else {
        glfwCreateWindow(winWidth, winHeight, "Blaster!", NULL, window)
    }
    if (!isFullscreen) {
        glfwSetWindowPos(new, winX, winY)
    }
    if (isHoldingCursor) {
        glfwSetInputMode(new, GLFW_CURSOR, GLFW_CURSOR_DISABLED)
    }
    if (isMultisampled) {
        glfwWindowHint(GLFW_SAMPLES, 4)
    }
    glfwSetWindowSizeCallback(new, windowSizeCallback)
    glfwSetMouseButtonCallback(new, mouseBtnCallback)
    glfwSetKeyCallback(new, keyCallback)
    glfwMakeContextCurrent(new)
    glfwSwapInterval(1)
    GLContext.createFromCurrent()
    glfwShowWindow(new)
    if (contextCreated.check()) {
        onCreate()
    }
    onResize(
            if (isFullscreen) fullWidth else winWidth,
            if (isFullscreen) fullHeight else winHeight)
    return new
}

One more thing to note is that we also load corresponding OpenGl binary libraries here:

desktop/com.blaster.platform.LwjglWindow::loadSharedLibs

private fun loadSharedLibs() {
    SharedLibraryLoader.load()
}

It works for both: Windows and Linux environments.

Cross-platform approach

Since the support for Windows and Linux comes naturally with LWJGL, I want to incorporate support for mobile devices as well additionally.

To do this, I abstracted all OpenGl calls behind an interface called GlBackend:

This interface is implemented for both cases: “big” OpenGl and mobile OpenGL ES. Since I am rarely using advanced OpenGL capabilities, this works just fine for me.

On the target platform I simply hook up the proper implementation with reflexion:

common_gl/com.blaster.gl.GlBackend::GlLocator

class GlLocator {
    companion object {
        private var instance: GlBackend? = null

        fun locate(): GlBackend {
            if (instance == null) {
                val clazz = Class.forName("com.blaster.gl.GlBackendImpl")
                val ctor = clazz.getConstructor()
                instance = ctor.newInstance() as GlBackend
            }
            return instance!!
        }
    }
}

Application resources

Since there are multiple platforms that I am supporting, I want to avoid the hustle with the resources between environments.

To achieve that, I am passing the resources as a part of a binary bundle – this is a sluggish and restricted solution. Still, I am intentionally not writing production-ready software.

To access resources, I have a class called AssetStream, which allows me to get the InputStream from the filename on every platform:

common_gl/com.blaster.assets.AssetStream

class AssetStream {
    fun openAsset(filename: String) : InputStream {
        val url = Thread.currentThread().contextClassLoader.getResource(filename)
        checkNotNull(url) { "Asset not found $filename" }
        return url.openStream()
    }
}

Later on, the specific resource can be decoded with one of the “libraries.” Here is one example:

common_gl/com.blaster.assets.ShadersLib

class ShadersLib(private val assetStream: AssetStream) {
    fun loadProgram(vertShaderAsset: String, fragShaderAsset: String) : GlProgram = GlProgram(
            GlShader(GlShaderType.VERTEX_SHADER, slurpAsset(vertShaderAsset)),
            GlShader(GlShaderType.FRAGMENT_SHADER, slurpAsset(fragShaderAsset)))

    private fun slurpAsset(filename: String): String {
        val stringBuilder = StringBuilder()
        val inputStream = assetStream.openAsset(filename)
        val bufferedReader = BufferedReader(InputStreamReader(inputStream, Charset.defaultCharset()))
        bufferedReader.use {
            var line = bufferedReader.readLine()
            while (line != null) {
                stringBuilder.append("$line\n")
                line = bufferedReader.readLine()
            }
        }
        return stringBuilder.toString()
    }
}

Scene: camera, controller, node

If you will think about it for a second – CG is a set of mathematical equations that are working with matrices. A single value is often a difference between a great lighting technique and a black screen of nothingness.

To avoid costly and time-consuming mistakes, I encapsulated most of the code related to scene matrix handling into easy to use abstractions. It is a ubiquitous approach when designing a rendering solution. In my case, a scene is represented by a graph of nodes. Each child node is transformed into his parents’ space by his parent matrix:

common_gl/com.blaster.entity.Node::calculateM

fun calculateM(): mat4 {
    val p = parent
    if (p == null) {
        modelM.set(calculateLocalM())
    } else {
        p.calculateM().mul(calculateLocalM(), modelM)
    }
    return modelM
}

His local matrix constructed as follows:

common_gl/com.blaster.entity.Node::calculateLocalM

private fun calculateLocalM(): mat4 {
    if (version.check()) {
        localM.identity().translationRotateScale(position, rotation, scale)
    }
    return localM
}

Version class guards from recalculating the matrix each time.

Camera also can construct its own matrix:

common_gl/com.blaster.entity.Camera::calculateViewM

fun calculateViewM(): mat4 {
    if (viewVersion.check()) {
        position.negate(negatedBuf)
        viewM.identity().rotate(rotation).translate(negatedBuf)
    }
    return viewM
}

Camera controlled through the class called controller:

common_gl/com.blaster.entity.Controller

data class Controller(
        val position: vec3 = vec3(),
        var yaw: Float = Math.toRadians(-90.0).toFloat(),
        var pitch: Float = 0f,
        var roll: Float = 0f,
        private val sensitivity: Float = 0.005f,
        private val velocity: Float = 0.01f
)

This class encapsulates a simple first-person camera code.

On the desktop I can directly map the input to the controller:

desktop/com.blaster.platform.WasdInput

class WasdInput(private val controller: Controller) {

    fun onCursorDelta(delta: Vector2f) {
        controller.yaw(delta.x)
        controller.pitch(-delta.y)
    }

    fun keyPressed(key: Int) {
        when (key) {
            GLFW.GLFW_KEY_W -> controller.moveForward = true
            GLFW.GLFW_KEY_A -> controller.moveLeft = true
            GLFW.GLFW_KEY_S -> controller.moveBack = true
            GLFW.GLFW_KEY_D -> controller.moveRight = true
            GLFW.GLFW_KEY_E -> controller.moveUp = true
            GLFW.GLFW_KEY_Q -> controller.moveDown = true
        }
    }

    fun keyReleased(key: Int) {
        when (key) {
            GLFW.GLFW_KEY_W -> controller.moveForward = false
            GLFW.GLFW_KEY_A -> controller.moveLeft = false
            GLFW.GLFW_KEY_S -> controller.moveBack = false
            GLFW.GLFW_KEY_D -> controller.moveRight = false
            GLFW.GLFW_KEY_E -> controller.moveUp = false
            GLFW.GLFW_KEY_Q -> controller.moveDown = false
        }
    }
}

Techniques

To draw in OpenGL, one needs to set up the pipeline in a certain way. It would help if you had shaders for passes, geometry, uniforms, lights, etc. Usually, this contains quite a lot of duplicating code. To efficiently reuse the code between different demos and projects, I group the necessary in classes called “Techniques.”

Here is the SimpleTechnique, which we will use for this demo:

common_gl/com.blaster.techniques.SimpleTechnique

A most straightforward technique: no lighting, just diffuse from textures

class SimpleTechnique {
    private lateinit var program: GlProgram

Compiling and storing shader program

    fun create(shadersLib: ShadersLib) {
        program = shadersLib.loadProgram("shaders/simple/no_lighting.vert", "shaders/simple/no_lighting.frag")
    }

While drawing, we pass the uniforms for the whole pass

    fun draw(camera: Camera, draw: () -> Unit) {
        glBind(listOf(program)) {
            program.setUniform(GlUniform.UNIFORM_VIEW_M, camera.calculateViewM())
            program.setUniform(GlUniform.UNIFORM_PROJ_M, camera.projectionM)
            draw.invoke()
        }
    }

    fun instance(model: Model, modelM: Matrix4f) {
        instance(model.mesh, model.diffuse, modelM)
    }

For each instance we pass unique uniforms: model matrix and diffuse texture handle

    fun instance(mesh: GlMesh, diffuse: GlTexture, modelM: Matrix4f) {
        glBind(listOf(mesh, diffuse)) {
            program.setUniform(GlUniform.UNIFORM_MODEL_M, modelM)
            program.setTexture(GlUniform.UNIFORM_TEXTURE_DIFFUSE, diffuse)

After uniforms are ready, we can make a call to render the geometry

            mesh.draw()
        }
    }
}

Demo shaders

Here are our shaders – very simple and straightforward.

Vertex shader:

common_assets/src/main/resources/shaders/simple/no_lighting.vert

#version 300 es

precision mediump float;

layout (location = 0) in vec3 aPosition;
layout (location = 1) in vec2 aTexCoord;

uniform mat4 uModelM;
uniform mat4 uProjectionM;
uniform mat4 uViewM;

out vec2 vTexCoord;

void main() {
    vTexCoord = aTexCoord;
    mat4 mvp =  uProjectionM * uViewM * uModelM;
    gl_Position = mvp * vec4(aPosition, 1.0);
}

And fragment shader:

common_assets/src/main/resources/shaders/simple/no_lighting.frag

#version 300 es

precision mediump float;

in vec2 vTexCoord;

uniform sampler2D uTexDiffuse;

layout (location = 0) out vec4 oFragColor;

void main() {

Retreiving the color directly from texture

    oFragColor = texture(uTexDiffuse, vTexCoord);

Discarding by alpha

    if (oFragColor.a < 0.1) {
        discard;
    }
}

Simple demo

Now we can finally discuss the demo itself.

Everything starts with onCreate method:

desktop/com.blaster.impl.Simple::onCreate

override fun onCreate() {

Bootstrapping our technique first

    simpleTechnique.create(shadersLib)

Creating a mesh and textures

    mesh = GlMesh.triangle()
    tex1 = texturesLib.loadTexture("textures/lumina.png")
    tex2 = texturesLib.loadTexture("textures/utah.jpeg")
    tex3 = texturesLib.loadTexture("textures/winner.png")
    model1 = Model(mesh, tex1)
    model2 = Model(mesh, tex2)
    model3 = Model(mesh, tex3)

Creating separate nodes to track three instances in space

    node1 = Node(payload = model1)
    node2 = Node(parent = node1, payload = model2)
    node3 = Node(parent = node2, payload = model3)
}

onResize allows to follow the current dimensions of the screen:

desktop/com.blaster.impl.Simple::onResize

override fun onResize(width: Int, height: Int) {
    GlState.apply(width, height)
    camera.setPerspective(width, height)
}

onTick is responsible for updating and drawing logic:

desktop/com.blaster.impl.Simple::onTick

override fun onTick() {
    GlState.clear()

Applying update from WASD controller

    controller.apply { position, direction ->
        camera.setPosition(position)
        camera.lookAlong(direction)
    }

Adding some animation to the scene

    node1.rotate(axis, 0.01f)
    node2.rotate(axis, 0.01f)
    node3.rotate(axis, 0.01f)

Drawing instances with our technique

    GlState.drawWithNoCulling {
        simpleTechnique.draw(camera) {
            simpleTechnique.instance(model1, node1.calculateM())
            simpleTechnique.instance(model2, node2.calculateM())
            simpleTechnique.instance(model3, node3.calculateM())
        }
    }
}

That is it – a pretty much standard introduction into the world of OpenGL.

Thanks for your attention and until next time!)

References


Leave a Reply

Your email address will not be published. Required fields are marked *