Tutorial 64: Cubemaps and Skyboxes

CNA Tutorials  ·  Advanced Rendering

ℹ

What you’ll learn

  • Building a TextureCube and what its six-face layout expects.
  • Rendering a skybox with inverted cube geometry.
  • The GLSL vertex and fragment shaders a skybox needs.
  • Sampling the same cubemap for reflections.
  • How large a cube each graphics profile allows, and which renderers have cube storage.

Before you start — Tutorial 08: Loading and Drawing Textures (cube faces are textures) and Tutorial 52: Writing Custom Shaders (ShaderEffect) (the skybox is drawn with a custom shader). Tutorial 56: EnvironmentMapEffect for Reflections pairs with this. Requires a 3D-capable renderer with cube-map support, such as OPENGLES3 or VULKAN (see Renderer support for cube maps for all 14 identities); the 2D-only SDL_RENDERER throws on 3D calls by default, and STUB draws nothing.

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 resolution, and the size you may use depends on the graphics profile. Under the default Reach profile a cube edge is limited to 512 and must be a power of two (a larger or non-power-of-two size throws NotSupportedException); under HiDef the limit is 4096 and non-power-of-two sizes are allowed. Common choices:

  • 256 × 256 — small skyboxes, mobile, or distant environment probes (Reach).
  • 512 × 512 — typical outdoor skybox quality (the largest cube Reach allows).
  • 1024 × 1024 — high-quality sky with visible clouds or stars at a distance (needs HiDef).
  • 2048 × 2048 — panoramic 360° environment maps with fine detail (needs HiDef); 96 MiB as six SurfaceFormat::Color (RGBA8, 4 bytes per texel) faces.

Signed-normalized surface formats are refused for cubes at either profile. Cubemaps can be loaded from a single DDS file with cubemap metadata, an XNB or a .cnj envelope, or built face by face in code (below). DDS is preferred for distribution because a single file carries all six faces and can carry block-compressed data (BC1/DXT1 for RGB cubemaps saves 6:1 memory vs. uncompressed) — but whether the GPU stores it compressed is renderer-dependent, see Renderer support for cube maps.

⚠

Requirements: profile limits for cube maps. CNA’s default GraphicsProfile is Reach, enforced on every renderer. Cubes of 1024 or 2048 pixels, or of any non-power-of-two size, throw NotSupportedException unless you request HiDef in the Game constructor, before Initialize() applies your preferences. The examples below use a 512 cube and need nothing; a larger environment map does:

SkyboxGame() : graphics_(this) {
    graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);
    // Or for the whole project, before the Game is constructed:
    //   CNA::SetProjectGraphicsProfileEXT(GraphicsProfile::HiDef);   // "CNA/ProjectGraphicsProfile.hpp"
}

The full list of profile ceilings, and the errors you will see when one is exceeded, is in Tutorial 152: Reach vs HiDef.

Loading via the Content System

The CNA content manager loads a TextureCube from a loose DDS cubemap file, from a .cnj envelope whose sourceFile names a DDS, or from an XNB TextureCube asset. It does not assemble a cube from six PNG files by a naming convention (there is no sky_px.png handling anywhere in the content system):

// Load a DDS cubemap file (preferred)
auto skyTex = getContentProperty().Load<TextureCube>("skybox/sky"); // loads sky.dds

To build a cube from six separate images, create a TextureCube programmatically and upload pixel data face by face using SetData. The count argument is the number of Color elements (size × size for a full face), not a byte count — a byte count throws:

int size = 512;   // Reach: at most 512, power of two; larger needs HiDef
auto sky = std::make_unique<TextureCube>(gd, size, false, SurfaceFormat::Color);

// Upload data for one face (e.g. the +X face): size * size Color elements
std::vector<Color> facePixels = loadFacePNG("sky_px.png");   // your own image loader
sky->SetData(CubeMapFace::PositiveX, facePixels.data(),
             static_cast<int>(facePixels.size()));
// Repeat for the other five faces...

// The full six-argument form updates one mip level or a sub-rectangle:
//   sky->SetData(face, level, /*rect*/ nullptr, data, startIndex, elementCount);
// Every argument is validated before anything is uploaded; the region is stored
// completely or the call throws.

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 — XNA (and CNA) treat clockwise-wound triangles as front faces, and the default state, CullCounterClockwise, culls the counter-clockwise ones. The mesh below is wound clockwise as seen from inside the cube, so the default already shows the inside and hides the outside. The winding-agnostic choice, and the one the example uses, is RasterizerState::CullNone: every triangle of a closed cube seen from within is an inside face anyway, so nothing is wasted. Do not pick CullClockwise for this mesh — it would cull exactly the faces you are looking at. (The winding was worked out by hand for XNA’s clockwise-front rule, not verified by rendering, which is one more reason to prefer CullNone.)

GLSL Skybox Vertex Shader

The shaders on this page are GLSL ES 3.00 (#version 300 es), the dialect of OPENGLES3 and WEBGL2. For OPENGL33 use #version 330 core (and drop the precision line); VULKAN needs SPIR-V, WEBGPU WGSL, and DIRECTX11 HLSL with a TextureCube. FNA3D and SOFTWARE cannot run a custom shader at all, and METAL runs one only in SpriteBatch; for them use the stock EnvironmentMapEffect (Tutorial 56) where the renderer supports it.

#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

⚠

The skybox shader here is a ShaderEffect. It takes renderer-native shader source, has no Parameters collection, and binds a cube texture with SetTexture(int unit, TextureCube&). This snapshot’s separate compiled-effect path accepts XNA/FNA D3D9 Effect Framework bytecode only on FNA3D and on builds that enabled a matching default-OFF CNA_*_COMPILED_EFFECTS option (11 of the 14 identities in all); it is not a runtime compiler for this page’s source. See Tutorial 52 and Tutorial 128.

#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "System/IO/File.hpp"

// A minimal camera helper used by the examples in this series. It is application
// code, not a CNA type; Tutorial 34 builds a fuller FpsCamera.
struct Camera {
    Matrix view = Matrix::CreateLookAt(Vector3(0.0f, 1.0f, 5.0f), Vector3::Zero, Vector3::Up);
    Matrix projection = Matrix::CreatePerspectiveFieldOfView(
        MathHelper::ToRadians(60.0f), 16.0f / 9.0f, 0.1f, 500.0f);
    const Matrix& View() const       { return view; }
    const Matrix& Projection() const { return projection; }
};

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

public:
    SkyboxGame() : graphics_(this) {}   // a 512 cube is inside Reach; see Requirements for larger

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

        // Load cubemap texture
        skyTex_ = getContentProperty().Load<TextureCube>("skybox/sky");

        // Three arguments: device, vertex source, fragment source. Not file paths.
        skyEffect_ = std::make_unique<ShaderEffect>(
            gd,
            System::IO::File::ReadAllText("Content/effects/skybox.vert.glsl"),
            System::IO::File::ReadAllText("Content/effects/skybox.frag.glsl"));

        // The constructor does not throw on a compile failure.
        // GetCompileErrorEXT() returns the compiler log (also written to stderr).
        if (!skyEffect_->IsEffectValid()) {
            // The shader did not compile. Do not draw with it.
        }

        // Tell the samplerCube which unit to read from, once.
        // Apply() first is the portable order for a ShaderEffect.
        skyEffect_->Apply();
        skyEffect_->SetUniformInt("u_skybox", 0);

        // 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.setDepthStencilStateProperty(DepthStencilState::DepthRead);
        // We are inside the cube. CullNone is independent of the mesh's winding;
        // with the winding below the default CullCounterClockwise would also work.
        gd.setRasterizerStateProperty(RasterizerState::CullNone);

        float viewCM[16], projCM[16];
        view.ToColumnMajor(viewCM);
        camera_.Projection().ToColumnMajor(projCM);

        skyEffect_->Apply();
        skyEffect_->SetUniformMat4("u_view",       viewCM);
        skyEffect_->SetUniformMat4("u_projection", projCM);
        // TextureCube overload of SetTexture; takes a reference, not a pointer.
        skyEffect_->SetTexture(0, *skyTex_);

        gd.SetVertexBuffer(skyboxVB_.get());
        gd.SetIndexBuffer(skyboxIB_.get());

        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.setDepthStencilStateProperty(DepthStencilState::Default);
        gd.setRasterizerStateProperty(RasterizerState::CullCounterClockwise);
        // No gd.Present(): Game presents after Draw() returns, so a manual call
        // would present the frame twice.
    }
};

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. CNA has no position-only vertex type (the shipped types are VertexPositionColor, VertexPositionTexture, VertexPositionColorTexture and VertexPositionNormalTexture, plus the CNAEXT skinned and tangent variants), so the skybox declares its own: a trivially copyable struct plus an explicit VertexDeclaration (custom layouts are the subject of Tutorial 51), uploaded with the generic SetData<T>. The custom shader reads only a_position at location 0. A helper function that builds it:

// A position-only vertex: a plain struct (no virtual functions) plus a declaration.
struct SkyVertex {
    Vector3 Position;
};

static const VertexDeclaration& skyVertexDeclaration() {
    static const VertexDeclaration decl(static_cast<int>(sizeof(SkyVertex)), {
        VertexElement(0, VertexElementFormat::Vector3, VertexElementUsage::Position, 0),
    });
    return decl;
}

void buildInvertedCube(GraphicsDevice& gd,
                        std::unique_ptr<VertexBuffer>& vb,
                        std::unique_ptr<IndexBuffer>&  ib) {
    // 8 corners of a unit cube centred at origin
    SkyVertex 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, skyVertexDeclaration(),
                                          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 (counter-clockwise from outside, clockwise 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 supports it directly: RenderTargetCube derives from TextureCube, and GraphicsDevice::SetRenderTarget(RenderTargetCube*, CubeMapFace) selects which face a pass draws into. It is not a matter of copying pixels back with SetData:

// LoadContent: a 512 cube is the largest Reach allows; larger needs HiDef
envCube_ = std::make_unique<RenderTargetCube>(gd, 512, false,
               SurfaceFormat::Color, DepthFormat::Depth24);

// Draw: one pass per face, each with a 90-degree view from the reflective object
for (int f = 0; f < 6; ++f) {
    gd.SetRenderTarget(envCube_.get(), static_cast<CubeMapFace>(f));
    gd.Clear(Color::CornflowerBlue);
    // ... draw the scene with the view/projection for face f ...
}
gd.SetRenderTarget(nullptr);

// envCube_ is a TextureCube: bind it like any other cube map
reflectEffect_->SetTexture(0, *envCube_);

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

Renderer support for cube maps

Cube storage and cube sampling are not identical across the 14 renderer identities, and a custom samplerCube shader additionally needs a renderer that executes custom shader source (see the note above the vertex shader).

Renderer identities Cube storage (TextureCube, RenderTargetCube) Block-compressed (DXT) cube faces
OPENGLES3, OPENGL33, WEBGL2 (EasyGL)YesBlocks are handed to the renderer; it decodes them on the CPU when the driver has no S3TC
VULKAN, SDL_GPU, DIRECTX11YesBlocks kept compressed (Vulkan needs textureCompressionBC enabled)
WEBGPUYesBlocks kept compressed when the device has BC texture compression
SOFTWAREYes (CPU storage)Blocks accepted
DIRECTX9, FNA3DYesDecompressed to Color by the content reader, so the memory saving is lost
METALYes, every uncompressed formatBlocks kept compressed where the GPU has BC (every Mac); decoded to RGBA8 beside the kept blocks where it has none (an iPhone GPU)
HEADLESSTrace only, no pixels—
STUBNo cube resource is created—
SDL_RENDERERNo (a null-object resource, or a throw under the default 3D-call policy)—