Tutorial 64: Cubemaps and Skyboxes

CNA Tutorials  ·  Advanced Rendering

A cubemap is a special GPU texture type consisting of six square 2D faces arranged like the sides of a cube. It is sampled with a 3D direction vector rather than 2D UV coordinates, making it the natural representation for environment maps, skyboxes, and reflection probes. CNA wraps cubemap textures in the TextureCube class, which mirrors the XNA 4.0 API.

TextureCube Class

TextureCube is a GPU resource holding six equal-resolution square 2D images. The six faces correspond to the six principal axis directions in a left-handed coordinate system:

Face IndexCubeMapFace EnumDirection
0PositiveX+X (right)
1NegativeX-X (left)
2PositiveY+Y (top)
3NegativeY-Y (bottom)
4PositiveZ+Z (front / forward)
5NegativeZ-Z (back)

In GLSL, a cubemap is declared as uniform samplerCube u_skybox; and sampled with texture(u_skybox, direction) where direction is a vec3. The GPU automatically selects the correct face and computes UV coordinates from the direction vector's dominant axis.

Six-Face Layout and Resolutions

All six faces must be square and the same power-of-two resolution. Common choices:

  • 256 × 256 — small skyboxes, mobile, or distant environment probes.
  • 512 × 512 — typical outdoor skybox quality.
  • 1024 × 1024 — high-quality sky with visible clouds or stars at a distance.
  • 2048 × 2048 — panoramic 360° environment maps with fine detail; 96 MB for RGB8.

Cubemaps can be stored as six individual image files or as a single DDS file with cubemap metadata. DDS is strongly preferred for distribution because it avoids the need for six separate file loads and supports GPU-native texture compression (BC1/DXT1 for RGB cubemaps saves 6:1 memory vs. uncompressed).

Loading via the Content System

The CNA content manager loads a TextureCube from a DDS cubemap file or from six PNG files following a naming convention:

// Option A: load from a DDS cubemap file (preferred)
auto skyTex = Content.Load<TextureCube>("skybox/sky"); // loads sky.dds

// Option B: load from six separate images
// Content system looks for: sky_px.png, sky_nx.png, sky_py.png,
//                           sky_ny.png, sky_pz.png, sky_nz.png
auto skyTex = Content.Load<TextureCube>("skybox/sky");

You can also create a TextureCube programmatically and upload pixel data face by face using SetData:

int size = 512;
auto sky = std::make_unique<TextureCube>(gd, size, false, SurfaceFormat::Color);

// Upload data for one face (e.g. the +X face)
std::vector<Color> facePixels = loadFacePNG("sky_px.png");
sky->SetData(CubeMapFace::PositiveX,
             0,          // mip level
             nullptr,    // full face rectangle
             facePixels.data(),
             0,
             static_cast<int>(facePixels.size() * sizeof(Color)));
// Repeat for the other five faces...

Skybox Rendering

Rendering a skybox correctly requires several special considerations:

  • Inverted cube — the cube's face normals must point inward (toward the camera) so the inside of the cube is visible. Achieve this by reversing the winding order of the triangles or by flipping the faces in the mesh.
  • No translation — the skybox must appear infinitely far away. Remove the camera's translation component from the view matrix. The skybox only rotates as the camera rotates; moving the camera must not change the skybox position.
  • Depth trick — force the skybox geometry to always render at the maximum depth (depth = 1.0 after the perspective divide) so all scene geometry appears in front of it. The vertex shader trick gl_Position = pos.xyww; achieves this: since gl_Position.z / gl_Position.w is what gets written to the depth buffer, setting z = w yields depth = 1.0 exactly.
  • Depth state — use DepthStencilState::DepthRead (depth test enabled, depth write disabled) when drawing the skybox. This ensures scene geometry in front of the skybox correctly passes the depth test, and the skybox does not overwrite the depth values needed for subsequent passes.
  • Culling — since we are drawing the inside of the cube, back-face culling must be inverted (RasterizerState::CullClockwise or culling disabled).

GLSL Skybox Vertex Shader

#version 300 es
precision highp float;

layout(location = 0) in vec3 a_position;

// View matrix with translation zeroed out on the C++ side
uniform mat4 u_view;
uniform mat4 u_projection;

out vec3 v_texcoord; // direction vector into the cubemap

void main() {
    // Pass the vertex position directly as the sample direction.
    // Since the cube is centred at the origin, vertex positions
    // ARE the correct direction vectors.
    v_texcoord = a_position;

    vec4 pos    = u_projection * u_view * vec4(a_position, 1.0);

    // Force depth to 1.0 after perspective divide:
    // gl_Position.z / gl_Position.w = w / w = 1.0
    gl_Position = pos.xyww;
}

GLSL Skybox Fragment Shader

#version 300 es
precision highp float;

in vec3 v_texcoord;

uniform samplerCube u_skybox;

out vec4 fragColor;

void main() {
    // The direction vector v_texcoord selects the correct
    // cubemap face and texel automatically.
    fragColor = texture(u_skybox, v_texcoord);
}

Complete C++ Skybox Example

class SkyboxGame final : public Game {
    std::unique_ptr<TextureCube>  skyTex_;
    std::unique_ptr<Effect>       skyEffect_;
    std::unique_ptr<VertexBuffer> skyboxVB_;
    std::unique_ptr<IndexBuffer>  skyboxIB_;
    Camera                        camera_;

    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();

        // Load cubemap texture
        skyTex_    = Content.Load<TextureCube>("skybox/sky");
        skyEffect_ = Content.Load<Effect>("effects/skybox");

        // Build an inverted (inside-facing) unit cube.
        // 8 unique corner positions, 36 indices (12 triangles).
        buildInvertedCube(gd, skyboxVB_, skyboxIB_);
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::Black);

        // Draw opaque scene geometry first (or use the depth trick
        // to draw skybox first without depth writes, order is flexible)
        drawScene(gd);

        // Strip the translation from the view matrix so the skybox
        // follows the camera rotation but not its position.
        Matrix view = camera_.View();
        view.M41 = 0.0f; // remove X translation
        view.M42 = 0.0f; // remove Y translation
        view.M43 = 0.0f; // remove Z translation

        // Depth read (no write) so scene geometry stays in front
        gd.setDepthStencilState(DepthStencilState::DepthRead);
        // Inward-facing cube: cull clockwise (which is the outside)
        gd.setRasterizerState(RasterizerState::CullClockwise);

        skyEffect_->Parameters["u_skybox"].SetValue(skyTex_.get());
        skyEffect_->Parameters["u_view"].SetValue(view);
        skyEffect_->Parameters["u_projection"].SetValue(camera_.Projection());

        gd.setVertexBuffer(*skyboxVB_);
        gd.setIndexBuffer(*skyboxIB_);

        for (auto& pass : skyEffect_->getCurrentTechnique().Passes) {
            pass.Apply();
            gd.DrawIndexedPrimitives(
                PrimitiveType::TriangleList,
                /*baseVertex*/    0,
                /*minVertexIndex*/0,
                /*numVertices*/   8,
                /*startIndex*/    0,
                /*primitiveCount*/12); // 12 triangles = 6 faces x 2
        }

        // Restore defaults before drawing transparent geometry, HUD, etc.
        gd.setDepthStencilState(DepthStencilState::Default);
        gd.setRasterizerState(RasterizerState::CullCounterClockwise);
        gd.Present();
    }
};

Inverted Cube Geometry

The inverted cube has the same 8 corner vertices as a regular cube, but the triangle winding order is reversed for each face so the GPU sees inward-facing normals. A helper function that builds it:

void buildInvertedCube(GraphicsDevice& gd,
                        std::unique_ptr<VertexBuffer>& vb,
                        std::unique_ptr<IndexBuffer>&  ib) {
    // 8 corners of a unit cube centred at origin
    VertexPosition verts[8] = {
        { Vector3(-1,-1,-1) }, { Vector3( 1,-1,-1) },
        { Vector3( 1, 1,-1) }, { Vector3(-1, 1,-1) },
        { Vector3(-1,-1, 1) }, { Vector3( 1,-1, 1) },
        { Vector3( 1, 1, 1) }, { Vector3(-1, 1, 1) },
    };
    vb = std::make_unique<VertexBuffer>(gd, VertexPosition::VertexDeclaration,
                                          8, BufferUsage::WriteOnly);
    vb->SetData(verts, 8);

    // 36 indices forming 12 triangles, winding reversed for inside faces
    uint16_t idx[36] = {
        0,2,1, 0,3,2,  // -Z face (front from outside, back from inside)
        4,5,6, 4,6,7,  // +Z face
        0,1,5, 0,5,4,  // -Y face (bottom)
        3,6,2, 3,7,6,  // +Y face (top)
        0,4,7, 0,7,3,  // -X face (left)
        1,2,6, 1,6,5,  // +X face (right)
    };
    ib = std::make_unique<IndexBuffer>(gd, IndexElementSize::SixteenBits,
                                         36, BufferUsage::WriteOnly);
    ib->SetData(idx, 36);
}

Cubemap Reflections

The same TextureCube used for the skybox can be used to simulate mirror-like surface reflections on objects. In the fragment shader, compute the reflection direction of the view vector about the surface normal and sample the cubemap with it:

// In the object's fragment shader
in vec3 v_worldNormal;
in vec3 v_worldPos;

uniform samplerCube u_envMap;
uniform vec3        u_cameraPos;

void main() {
    vec3 N       = normalize(v_worldNormal);
    vec3 V       = normalize(u_cameraPos - v_worldPos);
    vec3 R       = reflect(-V, N);          // reflection direction
    vec4 envColor = texture(u_envMap, R);   // sample cubemap

    // Mix surface albedo with reflection based on material reflectivity
    float reflectivity = 0.6;
    fragColor = mix(surfaceColor, envColor, reflectivity);
}

This gives plausible static reflections. For accurate dynamic reflections you would need to re-render the scene into the cubemap each frame from the reflective surface's position — expensive, but CNA's TextureCube supports SetData uploads from GPU render targets for this purpose.

For the built-in EnvironmentMapEffect that handles the reflection setup automatically, see Tutorial 56.