Cubemaps

Every texture so far has been a flat image read with a 2D coordinate. Direct3D has a few other kinds, and this chapter is about one that turns out to be surprisingly useful: the cubemap, six square images that together form the inside of a cube, stored and sampled as one texture.

Why bundle six images instead of binding six textures? Because of how a cubemap is read. We do not give it a uv coordinate; we give it a direction. Picture a small cube with the origin at its centre, and an arrow pointing out from the origin. The arrow pierces one of the six faces at some point, and the cubemap returns the texel found there. The length of the arrow is irrelevant, only where it points.

A direction vector from the centre of a cube picks the texel where it meets a face (LearnOpenGL's diagram)

LearnOpenGL's diagram shows the idea. Now suppose the cube we texture with a cubemap is itself centred on the origin. Then every point on its surface, its local vertex position, is also a direction from the centre, and the interpolated position is exactly the direction we need to sample with. A cube centred on the origin needs no texture coordinates at all: its positions are its texture coordinates.

Creating a cubemap

OpenGL has a dedicated texture target for cubemaps, GL_TEXTURE_CUBE_MAP, and fills it with six calls to glTexImage2D, one per face target. Direct3D has no separate cubemap resource. A cubemap is an ordinary 2D texture with an array size of six, created with one extra flag that allows it to be viewed as a cube. The six array slices are the faces, in a fixed order that matches OpenGL's:

Array sliceFaceOur image
0+xright.jpg
1-xleft.jpg
2+ytop.jpg
3-ybottom.jpg
4+zfront.jpg
5-zback.jpg

Since all six faces are part of one texture, they are all given at creation, the same way a buffer gets its contents: an array of six D3D11_SUBRESOURCE_DATA, one per slice. The rules that decide which face a direction lands on, and where on that face, are the same in both APIs, so the images LearnOpenGL uses load into Direct3D unchanged, with nothing flipped.

In HLSL the texture is declared as a TextureCube instead of a Texture2D, and sampled with a float3 direction instead of a float2:

TextureCube sky : register(t0);
SamplerState linear_wrap : register(s0);
...
    return float4(sky.Sample(linear_wrap, p.direction).rgb, 1.0);

LearnOpenGL sets the cubemap's wrap mode to GL_CLAMP_TO_EDGE so that directions landing exactly on a seam between two faces don't pick up texels from the wrong side. Direct3D 11 hardware filters across cube faces by itself (seamless cubemap filtering, which OpenGL only gets with GL_TEXTURE_CUBE_MAP_SEAMLESS), and the address mode plays no part in a cube lookup. That is why our lesson can sample the sky with the same wrapping sampler it would use for any other texture, bound once into s0.

So what is the point of all this? Quite a few techniques become easy once the surroundings live in a texture we can look up by direction. The best-known is the skybox.

Skybox

A skybox is a cube, textured with a cubemap, that encloses the whole scene and shows the far-away world: mountains, clouds, a night sky. It makes a small scene feel enormous, even though the player is in fact standing inside a tiny box. Cubemaps fit the job exactly, since a cube has six faces and each needs its own image.

Skybox images found online usually come as six separate files, or sometimes as one image laid out like an unfolded cube, which has to be cut into six. Folded back up, the six faces join without seams:

The six faces of a skybox laid out as an unfolded cube (LearnOpenGL's diagram)

This is LearnOpenGL's skybox, the lake among mountains, and it is the one in our resources/textures/skybox folder.

Loading a skybox

Everything above is wrapped in one procedure, load_cubemap in gpu.asm, which the later lessons with cubemaps share. It loads the six images, hands them to CreateTexture2D as the six slices of one texture, and makes a shader resource view that presents it as a cube:

; rcx: six image paths (an array of pointers: +x, -x, +y, -y, +z, -z) -> rax: a cubemap's view
;
; A cubemap is a texture array of six squares with a flag saying it is a cube; its faces are in
; the same order as GL's.
align 16
load_cubemap proc paths:qword
    local data[6]:D3D11_SUBRESOURCE_DATA, img:image, desc:D3D11_TEXTURE2D_DESC, view:D3D11_SHADER_RESOURCE_VIEW_DESC
    local texture:qword, srv:qword, face_size:dword
    USING rbx, rsi
    SaveRegs
    xor ebx, ebx
    lea rsi, data
    faces:
        mov rax, paths
        mov rdx, [rax + rbx * 8]
        invoke image_load, ADDR img, rdx
        test rax, rax
        jnz loaded
            mov rax, paths
            mov rdx, [rax + rbx * 8]
            print "Cubemap texture failed to load at path: %s\n", rdx
            invoke ExitProcess, 1
        loaded:
        mov [rsi].D3D11_SUBRESOURCE_DATA.pSysMem, rax
        mov eax, img._width
        mov face_size, eax
        shl eax, 2
        mov [rsi].D3D11_SUBRESOURCE_DATA.SysMemPitch, eax
        mov [rsi].D3D11_SUBRESOURCE_DATA.SysMemSlicePitch, 0
        add rsi, sizeof D3D11_SUBRESOURCE_DATA
        inc ebx
        cmp ebx, 6
        jb faces
    invoke mem_zero, ADDR desc, sizeof D3D11_TEXTURE2D_DESC
    mov eax, face_size
    mov desc._Width, eax
    mov desc.Height, eax
    mov desc.MipLevels, 1
    mov desc.ArraySize, 6
    mov desc.Format, DXGI_FORMAT_R8G8B8A8_UNORM
    mov desc.SampleDesc.Count, 1
    mov desc.BindFlags, D3D11_BIND_SHADER_RESOURCE
    mov desc.MiscFlags, D3D11_RESOURCE_MISC_TEXTURECUBE
    vcall device, ID3D11Device_CreateTexture2D, ADDR desc, ADDR data, ADDR texture
    AssertHr
    invoke mem_zero, ADDR view, sizeof D3D11_SHADER_RESOURCE_VIEW_DESC
    mov view.Format, DXGI_FORMAT_R8G8B8A8_UNORM
    mov view.ViewDimension, D3D11_SRV_DIMENSION_TEXTURECUBE
    mov view.TextureCube.MipLevels, 1
    vcall device, ID3D11Device_CreateShaderResourceView, texture, ADDR view, ADDR srv
    release texture
    mov rax, srv
    RestoreRegs
    ret
load_cubemap endp

The loop walks the six paths with ebx and the six D3D11_SUBRESOURCE_DATA entries with rsi, a pointer that steps by the structure's size; [rsi].D3D11_SUBRESOURCE_DATA.pSysMem is a field of the entry it points at. image_load returns the pixels in rax, or 0 if the file could not be read. Three things make this texture a cubemap rather than a stack of six pictures:

The faces of a cubemap must be square and all the same size, which is why the procedure keeps a single face_size. Our images are already square and equal, so it takes the width of the last one. The pixels stay where image_load put them, on the arena (saha.asm), for the life of the program, so nothing has to be freed after the texture is made.

The paths go in a six-element array of pointers, in face order. The lesson keeps the six file names as strings, and turns each into a full path with filesystem_path before handing the array over:

face_right byte "resources/textures/skybox/right.jpg", 0
face_left byte "resources/textures/skybox/left.jpg", 0
face_top byte "resources/textures/skybox/top.jpg", 0
face_bottom byte "resources/textures/skybox/bottom.jpg", 0
face_front byte "resources/textures/skybox/front.jpg", 0
face_back byte "resources/textures/skybox/back.jpg", 0
align 8
face_names qword face_right, face_left, face_top, face_bottom, face_front, face_back
faces qword 6 dup(0)            ; the same, as full paths
; the six faces, +x, -x, +y, -y, +z, -z
lea rsi, face_names
lea rbx, faces
lea rdi, faces + 6 * 8
paths:
    invoke filesystem_path, qword ptr [rsi]
    mov [rbx], rax
    add rsi, 8
    add rbx, 8
    cmp rbx, rdi
    jb paths
invoke load_cubemap, ADDR faces
mov skybox, rax

Displaying a skybox

A skybox is drawn on a cube, so we need a cube's vertices. We already have one: the unit cube of cube.asm, which runs from -0.5 to 0.5 on each axis and is centred on the origin, so its positions double as directions. Its vertices also carry normals and texture coordinates for the objects in the scene, and we simply reuse the same vertex buffer and the same input layout for the sky. The skybox vertex shader only asks for POSITION; an input layout is allowed to describe more attributes than a shader reads, and the rest are ignored.

invoke shader_create, ADDR skybox_shader, "src/4.advanced_d3d11/6.1.cubemaps/skybox.hlsl", ADDR cube_layout, cube_layout_count

The skybox shader lives in its own file, skybox.hlsl:

struct sky_vertex
{
    float4 position : SV_Position;
    float3 direction : DIRECTION;
};

sky_vertex vs_main(float3 position : POSITION)
{
    sky_vertex o;
    // The cube's corners double as the directions to look up.
    o.direction = position;
    float4 clip = mul(projection, mul(view, float4(position, 1.0)));
    // z = w: after the divide, depth is exactly 1, the far plane.
    o.position = clip.xyww;
    return o;
}

float4 ps_main(sky_vertex p) : SV_Target
{
    return float4(sky.Sample(linear_wrap, p.direction).rgb, 1.0);
}

The vertex shader passes the local position on as the lookup direction, and the rasterizer interpolates it across each face. The pixel shader samples the cube with it. (The .xyww on the position is an optimization we come to in a moment; for now read it as the ordinary clip-space position.)

There is a catch. We want the sky to stay infinitely far away: the player can walk as far as they like and never get any closer to the mountains. But the view matrix translates everything by the camera's position, so as the camera moves, the little sky cube would be left behind. What we want is for the sky to turn with the camera, but never move. Rotation lives in the upper-left 3x3 part of the view matrix and translation in the last column, so we clear the translation and keep the rest. LearnOpenGL does it with glm::mat4(glm::mat3(view)); math.asm has a procedure for it:

; rcx: out, rdx: a -> a with its translation removed: what a skybox is drawn with, so it turns
; with the camera but never gets closer.
align 16
m4_without_translation proc
    movups xmm0, [rdx]
    movups xmm1, [rdx + 16]
    movups xmm2, [rdx + 32]
    movups [rcx], xmm0
    movups [rcx + 16], xmm1
    movups [rcx + 32], xmm2
    xor eax, eax
    mov [rcx + 12], eax
    mov [rcx + 28], eax
    mov [rcx + 44], eax
    mov [rcx + 48], eax
    mov [rcx + 52], eax
    mov [rcx + 56], eax
    mov dword ptr [rcx + 60], 3F800000h
    ret
m4_without_translation endp

Our matrices are stored row by row, sixteen floats, so a row is one 16-byte movups. The first three rows are copied whole, and then the last float of each, at byte offsets 12, 28 and 44, is zeroed: that is the translation column. The bottom row becomes (0, 0, 0, 1), 3F800000h being the bits of 1.0. With the camera's translation gone, the camera is always at the centre of the sky cube, and only its orientation matters. Immediately the scene feels vast:

The lake and mountains of the skybox behind a mirror-like cube and sphere

The two objects in front are the subject of the second half of this chapter. The sky itself is everything behind them.

An optimization

LearnOpenGL first draws the skybox before anything else, with depth writes off, so that the scene is drawn over it. That works, but it runs the sky's pixel shader for every pixel on the screen, including all the ones that the scene will cover a moment later. It is cheaper to draw the sky last: by then the depth buffer holds the depth of everything in the scene, and the early depth test can reject every sky pixel that lies behind an object before its pixel shader runs.

The difficulty is that the sky cube is tiny: half a unit from the camera. Drawn last with the normal depth test, it would win nearly everywhere and cover the scene. We need the depth test to believe that the sky is as far away as anything can be, at depth 1.0, the far plane.

Recall from Coordinate Systems that after the vertex shader, the rasterizer divides the clip-space position by its w component, and from Depth testing that the resulting z is the depth that gets tested. If we make the vertex shader output w in place of z, the division gives \(z_{ndc} = w / w = 1\) for every vertex of the sky, whatever its distance. That is what clip.xyww does. Direct3D's depth range is 0 to 1, so 1 is exactly the far plane (in OpenGL it is the far plane too, at the top of GL's -1 to 1 range).

Two pieces of depth-stencil state go with it. The depth buffer is cleared to 1.0, and the sky's depth is also exactly 1.0, so the default comparison, D3D11_COMPARISON_LESS, would reject the sky even where nothing was drawn. We need less-or-equal. And there is no reason for the sky to write its depth, so writes are switched off:

; The sky is drawn where the depth is still the far plane, and must not write depth itself.
invoke mem_zero, ADDR depth_desc, sizeof D3D11_DEPTH_STENCIL_DESC
mov depth_desc.DepthEnable, 1
mov depth_desc.DepthWriteMask, D3D11_DEPTH_WRITE_MASK_ZERO
mov depth_desc.DepthFunc, D3D11_COMPARISON_LESS_EQUAL
vcall device, ID3D11Device_CreateDepthStencilState, ADDR depth_desc, ADDR sky_depth

OpenGL would call glDepthFunc(GL_LEQUAL) and glDepthMask(GL_FALSE) here; in Direct3D both settings live in one immutable state object, made once at startup. At the end of the frame we bind it, draw the sky, and put back the default state by binding null:

; draw skybox as last, with the translation removed from the view matrix
invoke m4_without_translation, ADDR t.view, ADDR t.view
invoke shader_use, ADDR skybox_shader
vcall context, ID3D11DeviceContext_OMSetDepthStencilState, sky_depth, 0
invoke m4_identity, ADDR model
invoke draw_vertices, transforms_buffer, ADDR t, cube_vb, 36, ADDR model
vcall context, ID3D11DeviceContext_OMSetDepthStencilState, 0, 0

m4_without_translation works in place here, output and input both t.view: it reads the rows into registers before writing anything. The next frame's transforms_from_camera rebuilds the full view matrix anyway. The sky's model matrix is the identity, so the cube stays centred where the camera is. We look at the cube from the inside, which means we are looking at the back of each face; that is fine here, since the lessons leave culling off (see Face culling).

Environment mapping

We now have the whole surrounding world in a single texture, and the skybox is only one thing to do with it. The other is to let objects pick up their surroundings: shiny ones reflect the environment, transparent ones bend it. Techniques that use a cubemap of the environment this way are called environment mapping, and the two classic ones are reflection and refraction.

Reflection

A mirror shows whatever lies in the direction a ray from your eye bounces off it. Which part of the world that is depends on where you stand: move, and the reflection changes. To compute it, we take the direction from the eye to the point being shaded, \(\bar{I}\), and mirror it around the surface normal \(\bar{N}\) to get the reflected direction \(\bar{R}\). Then we look up the cubemap along \(\bar{R}\).

The view vector I reflected around the normal N gives R, which samples the cubemap (LearnOpenGL's diagram)

HLSL has the mirroring built in, as reflect, the same as GLSL's. Our objects are drawn with cubemaps.hlsl, whose vertex shader is the one we have used since the lighting chapters: it passes on the world-space position and the normal, transformed by the normal matrix. The pixel shader does the rest:

float4 ps_main(vs_out p) : SV_Target
{
    float3 n = normalize(p.normal);
    float3 eye_ray = normalize(p.world - view_position);
    float3 lookup = refract_mode ? refract(eye_ray, n, 1.0 / 1.52) : reflect(eye_ray, n);
    return float4(sky.Sample(linear_wrap, lookup).rgb, 1.0);
}

eye_ray is \(\bar{I}\), pointing from the camera to the surface. Both it and the normal are in world space, the same space the cubemap's directions are in, since the sky is drawn with no rotation of its own. The camera position, view_position, comes from the shared transforms constant buffer. Before drawing, the cubemap is bound to t0 for the pixel shader:

; a turning cube and a sphere, both mirrors (or glass) of the sky
invoke update_buffer, env_buffer, ADDR env, sizeof env_constants
invoke shader_use, ADDR s
invoke bind_constants, 1, env_buffer
invoke bind_texture, 0, skybox
movss xmm1, current_frame
mulss xmm1, math_half
lea rcx, rotation
lea r8, y_axis
call m4_rotate
invoke m4_translate, ADDR model, ADDR cube_position
invoke m4_mul, ADDR model, ADDR model, ADDR rotation
invoke draw_vertices, transforms_buffer, ADDR t, cube_vb, 36, ADDR model
invoke m4_translate, ADDR model, ADDR sphere_position
invoke draw_vertices, transforms_buffer, ADDR t, sphere_vb, sphere_count, ADDR model

The cube turns by half a radian a second: m4_rotate takes its angle, a float and the second argument, in xmm1, so its arguments are set up by hand and it is called directly. The result is the screenshot above: a slowly turning cube on the left and a sphere on the right, both perfect mirrors. A cube makes a so-so mirror, since each face is flat and reflects one patch of sky, but the sphere shows the effect off well. LearnOpenGL draws its backpack model with the same shader; our lesson builds a sphere in code instead, with create_sphere, a grid of 64 by 64 quads wrapped around a unit sphere whose normals are simply its positions. Each vertex is worked out with sinf and cosf from math.asm, x87's fsin and fcos underneath, as there is no C runtime to call:

; p = (cos(2 pi u) sin(pi v), cos(pi v), sin(2 pi u) sin(pi v))
mulss xmm0, math_pi
call sinf
movss sin_v, xmm0
movss xmm0, v
mulss xmm0, math_pi
call cosf
movss real4 ptr [rdi + 4], xmm0
movss real4 ptr [rdi + 16], xmm0

Each result is stored twice, at the position's offset and 12 bytes further at the normal's: on a unit sphere the two are the same vector.

Real objects are rarely mirrors all over. A common refinement is a reflection map: a texture, like the specular maps of Lighting maps, that says how reflective each part of a surface is, so a model can have a shiny buckle on a dull strap.

Refraction

The other environment mapping effect is refraction: light changes direction when it passes from one material into another, as when a straw seems to bend where it enters a glass of water. The amount of bending is described by Snell's law, and depends on the two materials' refractive indices. Again we start with the view direction \(\bar{I}\) and the normal \(\bar{N}\), and get a bent direction \(\bar{R}\) to sample the cubemap with:

The view vector I bends as it passes through the surface, giving R (LearnOpenGL's diagram)

HLSL's refract takes the incoming direction, the normal, and the ratio of the refractive indices, from the material the light leaves to the one it enters. Some typical indices:

MaterialRefractive index
Air1.00
Water1.33
Ice1.309
Glass1.52
Diamond2.42

Our objects are glass, seen from air, so the ratio is \(\frac{1.00}{1.52} \approx 0.658\), the 1.0 / 1.52 in the shader above. Which of the two lookups the shader does is decided by refract_mode, an integer in a small second constant buffer at b1:

cbuffer env : register(b1)
{
    int refract_mode;
};

On the CPU side it is a structure padded out to 16 bytes, the size of one constant register, and Tab flips it each time it is pressed (window_mode with two modes):

; Mirrors `cbuffer env` in cubemaps.hlsl.
env_constants struct
    refract_mode dword ?
    pad dword 3 dup(?)
env_constants ends
invoke window_mode, 2
mov env.refract_mode, eax
The same cube and sphere refracting the sky like glass; the sphere shows the world upside down

The sphere now behaves like a glass ball, with the sky and the water turned upside down. The cube looks more like a slab of glass slightly shifting what is behind it. Different ratios give quite different looks; try water's 1.33 or diamond's 2.42.

Strictly, light that enters a glass ball also leaves it on the other side, and bends a second time there. We refract only once, at the surface facing us, which is a common simplification and good enough to read as glass. With the right mix of lighting, reflection, refraction and moving vertices, the same building blocks make convincing water.

Dynamic environment maps

Our environment is a fixed set of six photographs. That looks good, but the mirrors only ever show the sky: place a second object beside the sphere and it will not appear in the reflection, as if it were not there.

To fix that, we could render the scene six times from the object's position, once along each axis, into the six faces of a cubemap, and use that cubemap for its reflections. In Direct3D that means a cube texture created with D3D11_BIND_RENDER_TARGET as well as D3D11_BIND_SHADER_RESOURCE, and either one render target view per face (a 2D array view of a single slice), or one view of all six faces with a geometry shader choosing the face per triangle through SV_RenderTargetArrayIndex. The Point Shadows chapter does exactly that for depth. This is dynamic environment mapping, and its cost is plain: six extra renders of the scene per reflective object, per frame. Real-time applications get around it by relying on the skybox where possible, and by rendering environment cubemaps ahead of time, or rarely, wherever they can.

The full program is here, the objects' shader here and the skybox's here.

Additional resources

Exercises

  1. Draw the skybox first instead of last, the way LearnOpenGL starts out: with depth writes off and an ordinary position instead of .xyww. Check that the picture is the same, and think about which version runs fewer pixel shaders.
  2. Remove the m4_without_translation call and walk around with W, A, S and D. What happens to the sky, and why?
  3. Mix the two effects: compute both the reflected and the refracted colour in the pixel shader and blend them, using more reflection where the surface is seen at a grazing angle (for example with pow(1.0 - saturate(dot(-eye_ray, n)), 5.0) as the weight). This is a rough version of the Fresnel effect that returns in the PBR chapters.
Credit: this chapter follows LearnOpenGL's Cubemaps by Joey de Vries, rewritten for Direct3D 11 and x64 assembly. LearnOpenGL's text and code are licensed CC BY-NC 4.0, and so is this page; the wording, code and screenshots here are new, and diagrams marked as LearnOpenGL's are its own.