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
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.
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
| Array slice | Face | Our image |
|---|---|---|
| 0 | +x | right.jpg |
| 1 | -x | left.jpg |
| 2 | +y | top.jpg |
| 3 | -y | bottom.jpg |
| 4 | +z | front.jpg |
| 5 | -z | back.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 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:
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,
; 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. rax, or 0 if the file could not be read. Three things make this texture a cubemap rather than a stack of six pictures:
- ArraySize is 6: one slice per face, in the order of the table above. Each slice gets its own entry in data, with its pixel pointer and its row pitch (four bytes per pixel, the
shl eax, 2, sinceimage_load always returns RGBA). - MiscFlags holds D3D11_RESOURCE_MISC_TEXTURECUBE. Without it the texture is a plain 2D array, and Direct3D refuses to make a cube view of it.
- The shader resource view has the dimension D3D11_SRV_DIMENSION_TEXTURECUBE. This is what the shader's
TextureCubebinds to. The same texture could also be viewed as aTexture2DArray, and read slice by slice with 2D coordinates; the view decides.
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
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 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 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
t.view: it reads the rows into registers before writing anything. The next frame's
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
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}\).
HLSL has the mirroring built in, as 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: 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
; 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
Refraction
The other environment mapping effect is
HLSL's
| Material | Refractive index |
|---|---|
| Air | 1.00 |
| Water | 1.33 |
| Ice | 1.309 |
| Glass | 1.52 |
| Diamond | 2.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 (
; 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 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
The full program is here, the objects' shader here and the skybox's here.
Additional resources
- Introduction to textures in Direct3D 11: texture arrays and cubemaps, and how their subresources are numbered.
- TextureCube: the HLSL object and its methods.
- LearnOpenGL's Cubemaps: the OpenGL version of this chapter.
Exercises
- 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. - Remove the
m4_without_translation call and walk around with W, A, S and D. What happens to the sky, and why? - 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.