Framebuffers

Every lesson so far has drawn into two images: the back buffer, which holds colours and ends up on screen, and the depth-stencil buffer beside it. OpenGL calls such a set of images a framebuffer, and the one we have been using, created along with the window, the default framebuffer. Nothing forces us to draw into the window, though. We can draw the scene into a texture of our own, and then, since it is a texture, read it in a shader like any other. That opens up a lot: mirrors and security cameras showing a second view of the scene, shadows (in Shadow Mapping), and effects applied to the whole finished frame at once. This chapter builds the last kind, post-processing.

Creating a framebuffer

In OpenGL, a framebuffer is an object of its own. You create a framebuffer object (FBO), attach textures or renderbuffers to it as colour, depth and stencil attachments, check with glCheckFramebufferStatus that the combination is complete, and bind the FBO to send drawing into it.

Direct3D has no framebuffer object. We have already met the pieces it uses instead. A texture is plain storage, and how the pipeline may use it is decided by views of it: a render target view to draw colours into it, a depth-stencil view to use it as a depth buffer, and a shader resource view to read it in a shader. Drawing goes wherever OMSetRenderTargets says, and its arguments are exactly what a framebuffer is: up to eight render target views and one depth-stencil view. window_begin has been calling it every frame with the back buffer's view:

vcall context, ID3D11DeviceContext_OMSetRenderTargets, 1, ADDR backbuffer_rtv, depth_dsv

So "binding a framebuffer" means passing different views, and "the default framebuffer" means passing the back buffer's. There is nothing to attach and no completeness to check: what a view can be is checked when it is created (asking for a render target view of a texture created without the render target bind flag fails right there), and the combination is checked when it is bound, by the debug layer if it is enabled.

A texture to draw into

The texture we render the scene into needs two of those views: one to draw into it, and one to read it afterwards. gpu.asm keeps the three objects together, and makes them in one procedure:

; A texture that can be drawn into (rtv) and then read (srv): GL's framebuffer colour attachment.
render_target struct
    texture qword ?     ; ID3D11Texture2D
    rtv qword ?         ; ID3D11RenderTargetView
    srv qword ?         ; ID3D11ShaderResourceView
render_target ends

; rcx: &render_target, rdx: width, r8: height, r9: format, [rsp+32]: samples (1 unless MSAA)
align 16
create_render_target proc out_ref:qword, w:qword, h:qword, format:qword, samples:qword
    local desc:D3D11_TEXTURE2D_DESC
    USING rbx
    SaveRegs
    mov rbx, out_ref
    invoke mem_zero, ADDR desc, sizeof D3D11_TEXTURE2D_DESC
    mov eax, dword ptr w
    mov desc._Width, eax
    mov eax, dword ptr h
    mov desc.Height, eax
    mov desc.MipLevels, 1
    mov desc.ArraySize, 1
    mov eax, dword ptr format
    mov desc.Format, eax
    mov eax, dword ptr samples
    mov desc.SampleDesc.Count, eax
    mov desc.BindFlags, D3D11_BIND_RENDER_TARGET or D3D11_BIND_SHADER_RESOURCE
    lea rax, [rbx].render_target.texture
    vcall device, ID3D11Device_CreateTexture2D, ADDR desc, 0, rax
    AssertHr
    lea rax, [rbx].render_target.rtv
    vcall device, ID3D11Device_CreateRenderTargetView, qword ptr [rbx].render_target.texture, 0, rax
    lea rax, [rbx].render_target.srv
    vcall device, ID3D11Device_CreateShaderResourceView, qword ptr [rbx].render_target.texture, 0, rax
    RestoreRegs
    ret
create_render_target endp

This is the texture creation from Textures, with three differences. There is no initial data (the 0 in CreateTexture2D): we only reserve the memory, and drawing will fill it. There is one mip level, since we will read it at exactly its own size. And the bind flags say both RENDER_TARGET and SHADER_RESOURCE, which is what allows both views to be made. The views are created with a null description, meaning "the whole texture, in its own format". The samples argument is for Anti Aliasing; here it is always 1.

The procedure fills in a render_target the caller owns, through the pointer in rbx; [rbx].render_target.rtv is the field at its offset in the structure. The three Create calls each want the address of a field to write a pointer to, so it is computed with lea into rax and passed as the last argument; the texture itself is read from its field with qword ptr, the size MASM needs for a field reached through a register. samples, the fifth argument, arrives on the stack, past the four registers.

The texture is the size of the window, so the viewport we already have covers it exactly. If you render into a target of a different size, set a viewport of that size before drawing into it (gpu.asm has set_viewport for that), or only part of it gets drawn, as with glViewport in OpenGL.

A depth buffer to go with it

To draw a 3D scene into the texture we also need depth testing there, and so a depth buffer of the same size. OpenGL offers a renderbuffer for attachments that are written but never sampled, which lets the driver store them however suits it. Direct3D needs no separate kind of object for that: a texture created with only the depth-stencil bind flag cannot be read by shaders, which gives the driver the same freedom. That is the depth buffer window_create_depth makes for the window, and gpu.asm has it again for any size:

; rcx: width, rdx: height, r8: samples -> rax: ID3D11DepthStencilView of a new depth buffer,
; 24 bits of depth and 8 of stencil
align 16
create_depth_buffer proc w:qword, h:qword, samples:qword
    local desc:D3D11_TEXTURE2D_DESC, texture:qword, dsv:qword
    invoke mem_zero, ADDR desc, sizeof D3D11_TEXTURE2D_DESC
    mov eax, dword ptr w
    mov desc._Width, eax
    mov eax, dword ptr h
    mov desc.Height, eax
    mov desc.MipLevels, 1
    mov desc.ArraySize, 1
    mov desc.Format, DXGI_FORMAT_D24_UNORM_S8_UINT
    mov eax, dword ptr samples
    mov desc.SampleDesc.Count, eax
    mov desc.BindFlags, D3D11_BIND_DEPTH_STENCIL
    vcall device, ID3D11Device_CreateTexture2D, ADDR desc, 0, ADDR texture
    AssertHr
    vcall device, ID3D11Device_CreateDepthStencilView, texture, 0, ADDR dsv
    release texture                         ; the view keeps it alive
    mov rax, dsv
    ret
create_depth_buffer endp

The texture is released as soon as its view exists: the view holds a reference of its own, so the memory lives as long as the view does, and the view is all we keep. A depth buffer that we do want to sample later, as shadow mapping will, is made a little differently: with a typeless format, so that the same memory can be viewed as depth while drawing and as a float texture while reading. We will meet that when we need it.

At startup the lesson creates its off-screen colour target and depth buffer, and a clamping sampler for reading the colour target. It does not call window_create_depth: the window itself is only ever covered by one flat triangle, and needs no depth.

invoke create_sampler, D3D11_FILTER_MIN_MAG_MIP_LINEAR, D3D11_TEXTURE_ADDRESS_CLAMP
mov clamp_sampler, rax
; the scene's own colour target and depth buffer, the window's size
invoke create_render_target, ADDR scene_target, scr_width, scr_height, DXGI_FORMAT_R8G8B8A8_UNORM, 1
invoke create_depth_buffer, scr_width, scr_height, 1
mov scene_depth, rax

Clamping matters for the effects later on, which read neighbouring pixels: at the edge of the screen, the neighbour should be the edge pixel itself, not a pixel from the opposite edge.

Rendering to a texture

Each frame now has two passes:

  1. Bind the texture's render target view and the new depth buffer, and draw the scene exactly as before.
  2. Bind the back buffer again, and draw one shape covering the whole window whose pixel shader samples the texture.

The first pass is the depth testing scene, with a different target. window_begin only knows the window's buffers, so the binding and clearing it would do are written out here for the scene's:

; first pass: the scene, into the texture
; ---------------------------------------
call unbind_textures                    ; the texture was being read last frame
vcall context, ID3D11DeviceContext_OMSetRenderTargets, 1, ADDR scene_target.rtv, scene_depth
vcall context, ID3D11DeviceContext_RSSetViewports, 1, ADDR viewport
vcall context, ID3D11DeviceContext_ClearRenderTargetView, scene_target.rtv, floats(0.1, 0.1, 0.1, 1.0)
movf xmm3, 1.0                          ; the depth, a float: xmm3
vcall context, ID3D11DeviceContext_ClearDepthStencilView, scene_depth, D3D11_CLEAR_DEPTH, 0, 0
vcall context, ID3D11DeviceContext_IASetPrimitiveTopology, D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST
invoke bind_constants, 0, transforms_buffer
invoke transforms_from_camera, ADDR t, ADDR cam
invoke shader_use, ADDR s
vcall context, ID3D11DeviceContext_PSSetSamplers, 0, 1, ADDR sampler
invoke bind_texture, 0, floor_texture
invoke m4_identity, ADDR model
invoke draw_vertices, transforms_buffer, ADDR t, plane_vb, 6, ADDR model
; ...

Each target has its own contents, so each is cleared on its own: here the colour target and its depth buffer. ADDR scene_target.rtv is the address of the view inside the structure, which is the one-element array of views OMSetRenderTargets wants. The new line at the top is unbind_textures, from gpu.asm:

; Clears the pixel shader's texture registers t0..t7. A texture cannot be read and drawn into at
; once, so this runs before drawing into one that was just being read.
align 16
unbind_textures proc
    local views[8]:qword
    invoke mem_zero, ADDR views, 8 * 8
    vcall context, ID3D11DeviceContext_PSSetShaderResources, 0, 8, ADDR views
    ret
unbind_textures endp

At the end of the previous frame, the second pass left the scene texture bound to t0 for reading. Reading and writing the same texture in one draw would be a race, so Direct3D does not allow a texture to be bound as a render target and as a shader resource at the same time. If we tried, the runtime would quietly unbind one of them (and the debug layer would print a warning), which makes for confusing bugs. Clearing the shader resource slots, with eight null pointers, before binding the texture as a target keeps it explicit.

The rule works in both directions. Binding a texture's shader resource view while that texture is still bound as a render target gets the view replaced by null, and the shader samples black. The second pass below avoids this by switching the render target to the back buffer before binding the scene texture for reading.

One triangle over the screen

The second pass needs a shape covering the window. LearnOpenGL uses a quad of two triangles with its own vertex buffer. We use a single triangle big enough to contain the whole screen, and we do not need a vertex buffer for it at all:

// One triangle covering the screen, made from SV_VertexID alone: no
// vertex buffer, no input layout. uv runs 0..1 across and down the screen,
// and the parts of the triangle past the edges are clipped away.
struct fullscreen_vertex
{
    float4 position : SV_Position;
    float2 uv : TEXCOORD;
};

fullscreen_vertex vs_main(uint id : SV_VertexID)
{
    fullscreen_vertex o;
    o.uv = float2((id << 1) & 2, id & 2);
    o.position = float4(o.uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0);
    return o;
}

SV_VertexID is a system value the input assembler supplies by itself: the index of the vertex being processed, 0, 1, 2 for a draw of three vertices. The bit tricks turn those into the texture coordinates (0, 0), (2, 0) and (0, 2), and the next line maps those to clip space, with y flipped because v runs down while clip-space y runs up. The result is this triangle:

0: (-1, 1), uv (0, 0) 1: (3, 1), uv (2, 0) 2: (-1, -3), uv (0, 2) screen (1, -1), uv (1, 1) clipped away

The window, clip-space x and y from -1 to 1, is the top-left square of it, and inside that square uv runs from (0, 0) at the top-left to (1, 1) at the bottom-right: exactly the texture coordinates of the scene texture. The rest of the triangle is outside the screen and is clipped before any pixel of it is shaded. Compared with a quad, the single triangle has no diagonal seam through the middle, where GPUs shade some pixels twice because they work in 2x2 blocks, and it needs no buffer and no input layout. So the post shader is created with no layout (a 0 address and a count of 0), and shader_use then binds none:

; The screen pass makes its own triangle from SV_VertexID: no vertices, no input layout.
invoke shader_create, ADDR screen_shader, "src/4.advanced_d3d11/5.1.framebuffers/framebuffers_screen.hlsl", 0, 0

The second pass binds the back buffer with no depth buffer at all (there is nothing to test against, which is what LearnOpenGL gets by disabling the depth test), binds the scene texture and the clamp sampler, and draws three vertices:

; second pass: one triangle over the screen, reading the texture
; --------------------------------------------------------------
vcall context, ID3D11DeviceContext_OMSetRenderTargets, 1, ADDR backbuffer_rtv, 0
invoke update_buffer, post_buffer, ADDR post, sizeof post_constants
invoke shader_use, ADDR screen_shader
invoke bind_constants, 1, post_buffer
invoke bind_texture, 0, scene_target.srv
vcall context, ID3D11DeviceContext_PSSetSamplers, 0, 1, ADDR clamp_sampler
vcall context, ID3D11DeviceContext_Draw, 3, 0

The simplest pixel shader would only sample the texture and return the colour, and the window would show exactly what it showed in Depth testing. That is mode 1, which you get by pressing 1:

The two cubes on the floor, drawn into a texture and copied to the screen: it looks exactly as before

An unexciting picture, but every pixel of it went through a texture first. If yours is black, check that the scene texture is not bound for reading while it is still the render target, and that the second pass really samples t0 with a sampler bound.

Post-processing

Now that the finished frame is a texture, the second pixel shader can do anything with it before it reaches the screen, and whatever it does applies to the whole image at once. Keys 1 to 6 choose an effect, and Tab steps through them; the program starts in mode 6. The mode reaches the shader in a constant buffer:

cbuffer post : register(b1)
{
    int mode;
};

Texture2D scene_color : register(t0);
SamplerState linear_clamp : register(s0);
; Mirrors `cbuffer post` in framebuffers_screen.hlsl.
post_constants struct
    mode dword ?
    pad dword 3 dup(?)
post_constants ends

window_mode counts its modes from 0 and the keys from 1, so the lesson adds one to get the shader's numbering, and starts in window mode 5:

invoke window_mode_default, 5               ; edges
invoke window_mode, 6
inc eax
mov post.mode, eax

Inversion

The colour of every pixel is in our hands, so the simplest effect is to turn each one into its opposite:

float3 color = scene_color.Sample(linear_clamp, p.uv).rgb;
if (mode == 2) color = 1.0 - color;

Greyscale

Removing the colour leaves one brightness per pixel. Averaging red, green and blue would work, but our eyes are much more sensitive to green than to red, and least to blue, so a weighted sum looks more natural. These weights are the luminance of the Rec. 709 primaries that sRGB uses:

if (mode == 3) color = dot(color, float3(0.2126, 0.7152, 0.0722));

Assigning the scalar result of dot to a float3 copies it into all three channels.

Inverted: the dark scene becomes light, with white marble cubes and a pale backgroundGreyscale: the scene without colour

Our scene of grey marble and grey metal has little colour to lose, so the greyscale version (right) is a subtle change; it shows most on the faint brown tint of the floor.

Kernel effects

A single pixel's colour only gets us so far. Because the whole frame is in a texture, a pixel can also read its neighbours. A kernel (or convolution matrix) is a small grid of weights centred on the current pixel: each neighbour is multiplied by its weight and the results are added up. For example, this one:

\[ \begin{bmatrix} 2 & 2 & 2 \\ 2 & -15 & 2 \\ 2 & 2 & 2 \end{bmatrix} \]

takes twice each of the eight surrounding pixels and subtracts fifteen times the centre one. Most kernels' weights add up to 1, so that a flat area keeps its brightness; if they add up to more or less, the image gets brighter or darker. This one adds up to 1.

All our kernels are 3 by 3, so one HLSL function serves for all of them:

// Weighted sum of the 3x3 pixels around uv.
float3 kernel3x3(float2 uv, float k[9])
{
    float w, h;
    scene_color.GetDimensions(w, h);
    float2 texel = 1.0 / float2(w, h);
    float3 sum = 0.0;
    for (int y = -1; y <= 1; y++)
    {
        for (int x = -1; x <= 1; x++)
        {
            sum += k[(y + 1) * 3 + (x + 1)] * scene_color.Sample(linear_clamp, uv + float2(x, y) * texel).rgb;
        }
    }
    return sum;
}

GetDimensions asks the texture its size, so one step of texel is exactly one pixel. (LearnOpenGL uses a fixed offset of 1/300 instead, which is a few pixels wide at typical window sizes and makes its effects coarser.) Since uv is at a pixel's centre, every sample lands on a pixel's centre too, and the linear filtering returns that pixel unmixed. The weights are listed row by row, top row first: y = -1 is the row above, because v runs down.

A sharpen kernel exaggerates the difference between a pixel and its surroundings:

if (mode == 4)
{
    float k[9] = { -1, -1, -1, -1, 9, -1, -1, -1, -1 };
    color = kernel3x3(p.uv, k);
}
Sharpened: the marble veins and the edges of the cubes stand out harshly

The veins of the marble and the tile edges become crisp to the point of looking etched.

Blur

A blur kernel does the opposite, averaging each pixel with its neighbours, the nearer ones counting more:

\[ \frac{1}{16} \begin{bmatrix} 1 & 2 & 1 \\ 2 & 4 & 2 \\ 1 & 2 & 1 \end{bmatrix} \]

The weights add up to 16, so the sum is divided by 16 to keep the brightness:

if (mode == 5)
{
    float k[9] = { 1, 2, 1, 2, 4, 2, 1, 2, 1 };
    color = kernel3x3(p.uv, k) / 16.0;
}
Blurred: the whole scene slightly out of focus

With one-pixel steps the blur is gentle, like a camera slightly out of focus. Varying its strength over time could make a character dizzy; blurring only what is out of focus gives depth of field. Wider blurs are built from repeated or larger kernels, as Bloom will do.

Edge detection

Last, a kernel much like sharpen, but with weights adding up to 0:

\[ \begin{bmatrix} 1 & 1 & 1 \\ 1 & -8 & 1 \\ 1 & 1 & 1 \end{bmatrix} \]
if (mode == 6)
{
    float k[9] = { 1, 1, 1, 1, -8, 1, 1, 1, 1 };
    color = kernel3x3(p.uv, k);
}
return float4(color, 1.0);

Where a pixel is the same as its neighbours, the sum is 0, black. Only where the colour changes, at an edge, is anything left:

Edge detection: a black image with thin bright lines along the cubes' edges, the tile lines and the horizon

The outlines of the cubes, the grout lines of the marble and the horizon of the floor are drawn in thin bright lines; everything flat is gone. Image editors use exactly such kernels for their filters, and running them on the GPU, one pixel per thread, is what makes them fast enough to apply to every frame of a game. The full program is here, its scene shader here and its post-processing shader here.

Additional resources

Exercises

  1. Make a rear-view mirror: render the scene a second time into a smaller render target with the camera turned 180 degrees, then show that texture in a strip at the top of the screen (a second full-screen-triangle draw with a small viewport works).
  2. Apply the effect to only the left half of the screen, leaving the right half plain, by testing p.uv.x in the pixel shader.
  3. Invent kernels of your own, or look some up (emboss, Sobel), and add them as mode 7 and up (and raise the count passed to window_mode).
Credit: this chapter follows LearnOpenGL's Framebuffers 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.