Hello, and welcome to Greg’s Journal!

There are a lot of ways to overcome or at least hide the currently impossible in Computer Graphics. Computers, even today, have a very limited computational power. Frankly, the whole idea of rasterization revolves around the fact that real light simulation on the computer is not yet possible.

Today I would like to touch one of the techniques, which in my opinion have the greatest value to cost ratio. What? PBR? Nooo, skyboxes[1], of course! 🙂

In short, skyboxes allows you to add an immersive backgrounds to the scene, expand it, and give a sense of great depth. And they are almost

free in terms of work required.

Quake 3 Arena Skyboxes

If you are tired of:

common_gl/com.blaster.gl.GlState::setClearColor

fun setClearColor(color: color) {
    glCheck { backend.glClearColor(color.x, color.y, color.z, 1f) }
}

Then read on!

In this post

Cube mapping

To create skyboxes, we need to make our GlTexture class cube map [2] ready.

Cubemap is a special kind of texture, which internally represented by six texture maps. Here is how it works [3]:

Cubemap example

We are putting the camera inside of the cube and covering this cube with a set of textures. The important part is to remove any lighting

while rendering the cube. Otherwise, we will have an unwelcome ambient occlusion at corners.

Since I will be sending data about six different texture maps, which represents the sides of the cube, I have a class, which holds the

information in a neat package:

common_gl/com.blaster.gl.GlTexture::GlTexData

data class GlTexData(
        val internalFormat: Int = backend.GL_RGBA,
        val pixelFormat: Int = backend.GL_RGBA,
        val pixelType: Int = backend.GL_UNSIGNED_BYTE,
        val width: Int, val height: Int, val pixels: ByteBuffer?)

Uploading the maps to the GPU is straightforward. We are iterating through the sides and upload them one-by-one. The order is important here. GL_TEXTURE_CUBE_MAP_POSITIVE_X is the first index; consecutive indices are calculated by incrementing it:

common_gl/com.blaster.gl.GlTexture::loadCubemap

private fun loadCubemap(sides: List<GlTexData>) {
    glBind(this) {
        sides.forEachIndexed { index, side ->
            glCheck { backend.glTexImage2D(backend.GL_TEXTURE_CUBE_MAP_POSITIVE_X + index, 0,
                    side.internalFormat, side.width, side.height, 0,
                    side.pixelFormat, side.pixelType, side.pixels) }
        }
    }
}

The order is:

The actual constants might be different for your OpenGL driver.

Cube object itself

There are multiple ways how we can create the cube for mapping, and I am just loading an OBJ file with the appropriate cube. We are only interested in positions and texture coordinates:

common_assets/src/main/resources/models/cube/cube.obj

o cube

v -1.0 -1.0  1.0
v  1.0 -1.0  1.0
v -1.0  1.0  1.0
v  1.0  1.0  1.0
v -1.0  1.0 -1.0
v  1.0  1.0 -1.0
v -1.0 -1.0 -1.0
v  1.0 -1.0 -1.0

vt 0.0 0.0
vt 1.0 0.0
vt 0.0 1.0
vt 1.0 1.0

vn  0.0  0.0  1.0
vn  0.0  1.0  0.0
vn  0.0  0.0 -1.0
vn  0.0 -1.0  0.0
vn  1.0  0.0  0.0
vn -1.0  0.0  0.0

f 1/1/1 2/2/1 3/3/1
f 3/3/1 2/2/1 4/4/1

f 3/1/2 4/2/2 5/3/2
f 5/3/2 4/2/2 6/4/2

f 5/4/3 6/3/3 7/2/3
f 7/2/3 6/3/3 8/1/3

f 7/1/4 8/2/4 1/3/4
f 1/3/4 8/2/4 2/4/4

f 2/1/5 8/2/5 4/3/5
f 4/3/5 8/2/5 6/4/5

f 7/1/6 1/2/6 5/3/6
f 5/3/6 1/2/6 3/4/6

Skybox technique

Creating the SkyboxTechnique is straightforward as well. We need to load cube map shader, the texture itself, and the cube to render:

common_gl/com.blaster.techniques.SkyboxTechnique::create

fun create(shadersLib: ShadersLib, textureLib: TexturesLib, meshLib: MeshLib, skybox: String) {
    program = shadersLib.loadProgram("shaders/skybox/skybox.vert", "shaders/skybox/skybox.frag")
    diffuse = textureLib.loadSkybox(skybox)
    val (mesh, _) = meshLib.loadMesh("models/cube/cube.obj")
    cube = mesh
}

Rendering of the skybox is trivial. The only thing to note here is the operations on matrices. We are copying the “full” transformation matrix into the 3×3 rotation-only matrix, and then we are copying it back to 4×4 to remove the translation and scale components. The camera in the skybox technique is always at the origin of the scene:

common_gl/com.blaster.techniques.SkyboxTechnique::skybox

fun skybox(camera: Camera) {
    onlyRotationM.set(camera.calculateViewM())
    noTranslationM.set(onlyRotationM)
    glBind(listOf(program, cube, diffuse)) {
        program.setUniform(GlUniform.UNIFORM_PROJ_M, camera.projectionM)
        program.setUniform(GlUniform.UNIFORM_VIEW_M, noTranslationM)
        program.setTexture(GlUniform.UNIFORM_TEXTURE_DIFFUSE, diffuse)
        cube.draw()
    }
}

Skybox shader

Shaders are really similar to SimpleTechnique, the only difference is that the cube map is represented by samplerCube uniform. The math of calculating of the view ray intersection with the cube is conveniently hidden in texture(uTexDiffuse, vTexCoord) call:

common_assets/src/main/resources/shaders/skybox/skybox.frag

#version 300 es
precision mediump float;

in vec3 vTexCoord;
uniform samplerCube uTexDiffuse;

layout (location = 0) out vec4 oFragColor;

void main() {
    oFragColor = texture(uTexDiffuse, vTexCoord);
}

One neat trick is hidden in skybox vertex calculation, though:

common_assets/src/main/resources/shaders/skybox/skybox.vert

#version 300 es
precision mediump float;

layout (location = 0) in vec3 aPosition;

uniform mat4 uProjectionM;
uniform mat4 uViewM;

out vec3 vTexCoord;

void main() {
    vTexCoord = aPosition;
    vec4 pos = uProjectionM * uViewM * vec4(aPosition, 1.0);

Setting the vertex z == w, “The depth of a fragment in the Z-buffer is computed as z/w. If z==w, then you get a depth of 1.0, or 100%.” explanation

    gl_Position = pos.xyzz;
}

Skybox app

I am using skyboxes all over the place because of convenience and value, which they immediately add to the scene. Still, to showcase and tune the technique separately, I created a standalone app specifically for skyboxes:

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

override fun onCreate() {
    skyboxTechnique.create(shadersLib, texturesLib, meshLib, "textures/darkskies")
}

We use the GlState.drawWithNoCulling since we will render the skybox from inside of the cube:

desktop/com.blaster.impl.Skybox::drawSkybox

private fun drawSkybox() {
    GlState.drawWithNoCulling {
        skyboxTechnique.skybox(camera)
    }
}

Loading skyboxes

I am loading the skyboxes altogether from a single folder, and I also expect a certain naming inside of this folder. That makes the technique simpler to use later on:

common_gl/com.blaster.assets.TexturesLib::loadSkybox

fun loadSkybox(filename: String, unit: Int = 0): GlTexture {
    val file = File(filename)
    val right   = pixelDecoder.decodePixels(
            assetStream.openAsset(filename + "/" + file.name + "_rt.jpg"), mirrorX = true, mirrorY = true)
    val left    = pixelDecoder.decodePixels(
            assetStream.openAsset(filename + "/" + file.name + "_lf.jpg"), mirrorX = true, mirrorY = true)
    val top     = pixelDecoder.decodePixels(
            assetStream.openAsset(filename + "/" + file.name + "_up.jpg"), mirrorX = true, mirrorY = true)
    val bottom  = pixelDecoder.decodePixels(
            assetStream.openAsset(filename + "/" + file.name + "_dn.jpg"), mirrorX = true, mirrorY = true)
    val front   = pixelDecoder.decodePixels(
            assetStream.openAsset(filename + "/" + file.name + "_ft.jpg"), mirrorX = true, mirrorY = true)
    val back    = pixelDecoder.decodePixels(
            assetStream.openAsset(filename + "/" + file.name + "_bk.jpg"), mirrorX = true, mirrorY = true)
    return GlTexture(unit = unit, sides = listOf(
            GlTexData(width = right.width,  height = right.height,  pixels = right.pixels),
            GlTexData(width = left.width,   height = left.height,   pixels = left.pixels),
            GlTexData(width = top.width,    height = top.height,    pixels = top.pixels),
            GlTexData(width = bottom.width, height = bottom.height, pixels = bottom.pixels),
            GlTexData(width = front.width,  height = front.height,  pixels = front.pixels),
            GlTexData(width = back.width,   height = back.height,   pixels = back.pixels)))
}

One can notice the weird mirroring in the loading code. That is due to how the cube mapping coordinate system works:

“Cube Maps have been specified to follow the RenderMan specification (for whatever reason), and RenderMan assumes the images origin is in the upper left, contrary to the usual OpenGL behavior of having the image origin in the lower left. That is why things get swapped in the Y direction. It totally breaks with the usual OpenGL semantics and does not make sense at all. But now we are stuck with it.” [4]

Quake 3 Arena Skyboxes

I also wanted to mention a great pack of Quake 3 Arena skyboxes floating around the Internet. They are old, low-res, amateur, and retro – just how I like it 🙂

A bunch of alien Saucers is making short work of our home planet:

Attacking Saucers

Here is a link to one of the known mirrors of the collection:

Quake 3 Arena Skyboxes Collection

And with this handy script, I can convert them to jpeg as a batch and rotate the up and down textures:

common_assets/skybox.sh

cd src/main/resources/textures/$1
mogrify -format jpg *.tga;
mogrify -rotate 90 $1_up.jpg;
mogrify -rotate -90 $1_dn.jpg;
rm *tga

Conclusion

Okay, that was a lot to mention 🙂

Skyboxes are a great way to fill the void in the scene. They immediately can raise the level of presentation from a homebrew “tutorial” to something more digestible. With a clean abstraction, they are also highly reusable, since they do not require any integration with the rest of the scene.

That was it for today, hope you liked it. Will see you in the next posts, have a great day!

References


Leave a Reply

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