Blending

So far every surface has been solid: whatever we drew replaced what was behind it. Glass, water, smoke and leaves are not like that. A tinted window has a colour of its own, but you see the room behind it too, mixed with that colour. Producing such a mix is called blending: the colour of the new pixel is combined with the colour already in the render target, instead of overwriting it.

Two windows over the same room: a fully transparent one, and a partially transparent red one that tints the room behind it (LearnOpenGL's diagram)

How much of an object shows, and how much of what is behind it, is given by the fourth component of its colour, alpha. We have been setting it to 1.0 all along, meaning fully opaque. An alpha of 0.0 is fully transparent, and 0.5 means half the object's colour and half the background's. Textures can carry an alpha per texel. The window texture used in this chapter (LearnOpenGL's) has a partly transparent red glass and fully transparent corners, and on a white page it looks like this:

A window texture: a dark frame around light red, partly transparent glass (LearnOpenGL's diagram)

Before blending proper, there is an easier case: texels that are either completely there or completely not.

Discarding pixels

Grass is the classic example. Modelling every blade would be expensive, so games paste a picture of a tuft of grass onto a flat quad and stand it on the ground. The picture is square but the grass is not, so its texture marks the gaps with alpha 0 and the grass with alpha 1, with nothing in between. All we need is to drop the pixels where the texture is transparent.

Loading such a texture needs nothing new. image_load in image.asm always decodes to four channels, RGBA, through Windows' own codecs, and load_texture always makes an R8G8B8A8 texture, so the alpha channel has been arriving in every texture all along; for a JPEG it is simply 1 everywhere. (In OpenGL you have to ask for GL_RGBA when creating the texture.) The pixel shader only has to look at the sampled alpha instead of ignoring it:

Texture2D albedo : register(t0);
SamplerState sampler0 : register(s0);   // wrap for the floor, clamp for grass and windows

float4 ps_main(vs_out p) : SV_Target
{
    float4 color = albedo.Sample(sampler0, p.uv);
    // Throw the pixel away when it is almost fully transparent (GL: discard).
    clip(color.a - 0.1);
    return color;
}

HLSL's clip discards the pixel when its argument is negative, here when alpha is below 0.1. A discarded pixel writes nothing at all: no colour, no depth, no stencil. GLSL writes the same thing as if (texColor.a < 0.1) discard;, and HLSL has a discard statement too; clip is the shorthand. The threshold of 0.1 rather than exactly 0 allows for filtering, which smears a little alpha across the edge of the grass.

The grass quad is new geometry: a 1 by 1 square standing upright on y = 0, facing +z.

; A unit square standing on its bottom edge, facing +z: one blade of grass or one window.
quad_vertices real4 -0.5, 0.0, 0.0,   0.0, 0.0, 1.0,   0.0, 1.0
              real4  0.5, 0.0, 0.0,   0.0, 0.0, 1.0,   1.0, 1.0
              real4  0.5, 1.0, 0.0,   0.0, 0.0, 1.0,   1.0, 0.0
              real4 -0.5, 0.0, 0.0,   0.0, 0.0, 1.0,   0.0, 1.0
              real4  0.5, 1.0, 0.0,   0.0, 0.0, 1.0,   1.0, 0.0
              real4 -0.5, 1.0, 0.0,   0.0, 0.0, 1.0,   0.0, 0.0
quad_vertices_size equ 6 * 32

Note the texture coordinates: v is 1 at the bottom of the quad and 0 at the top, because Direct3D's v runs down the image. A handful of positions place the tufts, at y = -0.5 so that their bottom edge stands on the floor:

grass_positions vec3 <-2.2, -0.5, 0.4>
                vec3 <2.6, -0.5, 1.2>
                vec3 <0.6, -0.5, 1.4>
                vec3 <-0.6, -0.5, -1.8>

Without the clip, each tuft would be a square of the texture's colour, with the transparent parts drawn as whatever colour the image stores there. With it, only the grass is left. Since what survives is fully opaque, the depth buffer handles it like any other solid geometry, and the order the tufts are drawn in does not matter.

Clamping the edges

One more detail. Our sampler wraps, so a coordinate just past 1.0 reads from just past 0.0. Bilinear filtering at the very top row of a texture therefore mixes in a little of the bottom row. For a tiled floor that is the point, but for a grass image whose top is transparent and whose bottom is solid, it leaves a faint line along the top edge of the quad. The fix is to clamp instead, so the edge texel is repeated outwards. In OpenGL that is a property of the texture (GL_CLAMP_TO_EDGE). In Direct3D, filtering and addressing belong to the sampler, a separate object, so we make a second sampler with create_sampler from gpu.asm and bind it into s0 for the grass and the windows:

invoke create_sampler, D3D11_FILTER_ANISOTROPIC, D3D11_TEXTURE_ADDRESS_WRAP
mov sampler, rax
invoke create_sampler, D3D11_FILTER_ANISOTROPIC, D3D11_TEXTURE_ADDRESS_CLAMP
mov clamp_sampler, rax
; grass: clipped, so in any order
vcall context, ID3D11DeviceContext_PSSetSamplers, 0, 1, ADDR clamp_sampler
invoke bind_texture, 0, grass
lea rsi, grass_positions
xor ebx, ebx
blades:
    invoke m4_translate, ADDR model, rsi
    invoke draw_vertices, transforms_buffer, ADDR t, quad_vb, 6, ADDR model
    add rsi, sizeof vec3
    inc ebx
    cmp ebx, 4
    jb blades

The same texture could be drawn with either sampler; that choice is now made per draw rather than per texture.

Blending

Discarding is all or nothing. For the windows we want to see the glass and what is behind it, which needs real blending. Blending happens in the output merger, after the pixel shader, and combines two colours: the source, the colour the pixel shader just returned, and the destination, the colour already stored in the render target. The general form is

\[ C_{result} = C_{source} \cdot F_{source} + C_{destination} \cdot F_{destination} \]

where the two \(F\) are blend factors we choose. Take LearnOpenGL's example: a red square is already in the target, and a green square with alpha 0.6 is drawn over it.

A red square with colour (1, 0, 0, 1) and a green square with colour (0, 1, 0, 0.6) (LearnOpenGL's diagram)

We want the green to contribute 60% and the red behind it the remaining 40%. So the source factor is the source's alpha, and the destination factor is one minus that:

\[ C_{result} = \begin{pmatrix} 0.0 \\ 1.0 \\ 0.0 \\ 0.6 \end{pmatrix} \cdot 0.6 + \begin{pmatrix} 1.0 \\ 0.0 \\ 0.0 \\ 1.0 \end{pmatrix} \cdot (1 - 0.6) = \begin{pmatrix} 0.4 \\ 0.6 \\ 0.0 \\ \ldots \end{pmatrix} \] The squares overlapping: where they overlap the colour is 60% green and 40% red (LearnOpenGL's diagram)

This "source alpha, one minus source alpha" pair is ordinary transparency, and nearly the only blend you will use for see-through surfaces. In OpenGL it is glEnable(GL_BLEND) plus glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA). In Direct3D it is, as usual, a state object: a blend state, made from a description.

; -> rax: a blend state for transparency: out = src * src_alpha + dst * (1 - src_alpha)
align 16
create_alpha_blend proc
    local desc:D3D11_BLEND_DESC, state:qword
    invoke mem_zero, ADDR desc, sizeof D3D11_BLEND_DESC
    mov desc.RenderTarget.BlendEnable, 1
    mov desc.RenderTarget.SrcBlend, D3D11_BLEND_SRC_ALPHA
    mov desc.RenderTarget.DestBlend, D3D11_BLEND_INV_SRC_ALPHA
    mov desc.RenderTarget.BlendOp, D3D11_BLEND_OP_ADD
    mov desc.RenderTarget.SrcBlendAlpha, D3D11_BLEND_ONE
    mov desc.RenderTarget.DestBlendAlpha, D3D11_BLEND_INV_SRC_ALPHA
    mov desc.RenderTarget.BlendOpAlpha, D3D11_BLEND_OP_ADD
    mov desc.RenderTarget.RenderTargetWriteMask, D3D11_COLOR_WRITE_ENABLE_ALL
    vcall device, ID3D11Device_CreateBlendState, ADDR desc, ADDR state
    AssertHr
    mov rax, state
    ret
create_alpha_blend endp

The settings are per render target: RenderTarget is an array of eight, and a pass that draws into several targets at once can blend each one differently. desc.RenderTarget.BlendEnable names a field of the array's first element, RenderTarget[0], which is the only one we fill. SrcBlend and DestBlend are the two factors, and BlendOp how the two halves are combined. INV_SRC_ALPHA is Direct3D's name for "one minus source alpha": INV always means "one minus". The full set of factors mirrors OpenGL's:

FactorValueOpenGL
D3D11_BLEND_ZERO\(0\)GL_ZERO
D3D11_BLEND_ONE\(1\)GL_ONE
D3D11_BLEND_SRC_COLOR\(C_{source}\)GL_SRC_COLOR
D3D11_BLEND_INV_SRC_COLOR\(1 - C_{source}\)GL_ONE_MINUS_SRC_COLOR
D3D11_BLEND_DEST_COLOR\(C_{destination}\)GL_DST_COLOR
D3D11_BLEND_INV_DEST_COLOR\(1 - C_{destination}\)GL_ONE_MINUS_DST_COLOR
D3D11_BLEND_SRC_ALPHAsource alphaGL_SRC_ALPHA
D3D11_BLEND_INV_SRC_ALPHA1 - source alphaGL_ONE_MINUS_SRC_ALPHA
D3D11_BLEND_DEST_ALPHAdestination alphaGL_DST_ALPHA
D3D11_BLEND_INV_DEST_ALPHA1 - destination alphaGL_ONE_MINUS_DST_ALPHA
D3D11_BLEND_BLEND_FACTORa constant colourGL_CONSTANT_COLOR
D3D11_BLEND_INV_BLEND_FACTOR1 - the constant colourGL_ONE_MINUS_CONSTANT_COLOR

The constant colour, OpenGL's glBlendColor, is given when the state is bound rather than stored in it, as the stencil reference was in the previous chapter.

Alpha gets factors of its own: SrcBlendAlpha, DestBlendAlpha and BlendOpAlpha. OpenGL only separates them if you call glBlendFuncSeparate; Direct3D always asks. We blend alpha with ONE and INV_SRC_ALPHA, so the stored alpha becomes \(a_s + a_d(1 - a_s)\): coverage accumulates as layers are drawn, which is the right answer if the image is ever composited over something else. The back buffer's alpha is never shown on screen, so here it makes no visible difference.

The operation does not have to be an addition. BlendOp can be D3D11_BLEND_OP_ADD (source plus destination, the usual), SUBTRACT (source minus destination), REV_SUBTRACT (destination minus source), MIN or MAX, the same five as glBlendEquation. Last, RenderTargetWriteMask picks which channels are written at all; D3D11_COLOR_WRITE_ENABLE_ALL writes red, green, blue and alpha.

Rendering semi-transparent textures

Now the windows. They use the same quad, a different texture, and five positions of their own:

window_positions vec3 <-1.5, -0.5, -0.48>
                 vec3 <1.5, -0.5, 0.51>
                 vec3 <0.0, -0.5, 0.7>
                 vec3 <-0.3, -0.5, -2.3>
                 vec3 <0.5, -0.5, -0.6>

The blend state is bound only for them, and set back to null (no blending) afterwards, together with the wrap sampler:

; windows: blended, so the farthest first
call sort_windows
vcall context, ID3D11DeviceContext_OMSetBlendState, alpha_blend, 0, D3D11_DEFAULT_SAMPLE_MASK
invoke bind_texture, 0, glass
xor ebx, ebx
windows:
    lea rax, window_order
    mov eax, [rax + rbx * 4]
    imul eax, eax, sizeof vec3
    lea rsi, window_positions
    add rsi, rax
    invoke m4_translate, ADDR model, rsi
    invoke draw_vertices, transforms_buffer, ADDR t, quad_vb, 6, ADDR model
    inc ebx
    cmp ebx, 5
    jb windows
vcall context, ID3D11DeviceContext_OMSetBlendState, 0, 0, D3D11_DEFAULT_SAMPLE_MASK
vcall context, ID3D11DeviceContext_PSSetSamplers, 0, 1, ADDR sampler

The second argument of OMSetBlendState is that constant blend colour; null means (1, 1, 1, 1), and we do not use it. The third is a sample mask for multisampled targets (we return to samples in Anti Aliasing); D3D11_DEFAULT_SAMPLE_MASK is all ones, every sample.

The windows go through the same pixel shader as the grass, clip included. Their glass has an alpha well above 0.1, so it survives and is blended; only the fully transparent corners of the texture are dropped. That has a small benefit we will appreciate in a moment: those corners write no depth.

Notice window_order in the loop: the window drawn ebx-th is the one whose index is in window_order[ebx], and its position is found by multiplying that index by the size of a vec3. If we drew the windows in the order of the array, some would look wrong: a window seen through another window would be missing, as if the nearer glass were opaque. The cause is the depth buffer. A blended pixel still writes its depth like any other. If the near window is drawn first, its glass fills the depth buffer at its distance, and when the far window comes along afterwards, its pixels are behind and fail the depth test. They are never blended in, because they never get that far.

Blending reads what is already in the render target, so whatever should show through a transparent surface has to be there before the surface is drawn. Hence the rule.

Don't break the order

When a scene mixes opaque and transparent objects:

  1. Draw everything opaque first, in any order; the depth buffer sorts it out.
  2. Sort the transparent objects by distance from the camera.
  3. Draw the transparent objects from the farthest to the nearest.

The opaque floor and cubes come first, then the grass, which counts as opaque since clip leaves only solid pixels. For the windows, LearnOpenGL puts the distances in a std::map, which keeps its keys sorted, and walks it backwards. We have five windows, so we just sort five indices directly, every frame, by their distance to the camera. sort_windows first works out each window's distance into an array of five floats and starts the order as 0 to 4, then runs a selection sort over the distances, carrying the order along:

; Puts the windows in window_order from the farthest from the camera to the nearest.
align 16
sort_windows proc
    USING rbx, rsi, rdi, r12
    SaveRegs
    ; A global indexed by a register needs its address in a register too: [label + rbx * 4] would
    ; be a 32-bit absolute address, which a 64-bit program loaded above 4 GB cannot use.
    lea rdi, window_order
    lea r12, window_distance
    xor ebx, ebx
    distances:
        mov [rdi + rbx * 4], ebx
        imul eax, ebx, sizeof vec3
        lea rdx, window_positions
        add rdx, rax
        invoke v3_sub, ADDR offset_to_window, rdx, ADDR cam.position
        invoke v3_length, ADDR offset_to_window
        movss real4 ptr [r12 + rbx * 4], xmm0
        inc ebx
        cmp ebx, 5
        jb distances
    ; selection sort on the distances, carrying the order along
    xor ebx, ebx
    outer:
        lea esi, [ebx + 1]
        inner:
            cmp esi, 5
            jae inner_done
            movss xmm0, real4 ptr [r12 + rsi * 4]
            comiss xmm0, real4 ptr [r12 + rbx * 4]
            jbe in_order
                movss xmm1, real4 ptr [r12 + rbx * 4]
                movss real4 ptr [r12 + rbx * 4], xmm0
                movss real4 ptr [r12 + rsi * 4], xmm1
                mov eax, [rdi + rbx * 4]
                mov edx, [rdi + rsi * 4]
                mov [rdi + rbx * 4], edx
                mov [rdi + rsi * 4], eax
            in_order:
            inc esi
            jmp inner
        inner_done:
        inc ebx
        cmp ebx, 4
        jb outer
    RestoreRegs
    ret
sort_windows endp

Each slot ebx ends up holding the farthest of the windows not yet placed: whenever a later slot esi is farther, the two swap, both their distances and their indices. comiss compares two floats and sets the flags the way an unsigned comparison of integers would, which is why the jump is jbe ("below or equal") rather than jle. Sorting indices rather than positions means the positions array is never touched, and a map would only add allocations for no gain.

Both arrays are globals, and the sort indexes them by a register. That cannot be written window_distance[rbx * 4]: a global is normally addressed relative to the instruction pointer, and an address with an index register has no room for that, so MASM would emit a 32-bit absolute address, which the 64-bit linker refuses. Hence the lea rdi, window_order and lea r12, window_distance first, and [rdi + rbx * 4] after; the drawing loop does the same with lea rax, window_order. Locals have no such problem, being addressed from rbp.

With the windows drawn back to front, every window sees the ones behind it:

Grass tufts and red-tinted windows over the floor and cubes; windows behind other windows show through correctly

Look at the middle: a tuft of grass and the side of a cube show through the glass, and where two windows overlap the tint is deeper, one layer of red on top of another. The full program is here, and its shader here.

Sorting by the distance to an object's position works for these small, flat, unrotated quads. It is not a general solution. Large objects can be partly in front of and partly behind each other, so no order is right; objects that intersect cannot be sorted at all; and a concave transparent mesh can overlap itself. Engines deal with this in various ways: sorting per triangle, splitting meshes, or order-independent transparency, a family of techniques that store several layers per pixel and sort them on the GPU. Those are beyond this chapter. In practice, knowing the limits of simple sorting and designing scenes around them goes a long way.

Additional resources

Exercises

  1. Draw the windows in array order (skip the call sort_windows, and fill window_order with 0 to 4 once) and fly around until you find the depth problem described above.
  2. Remove the clip from the pixel shader and look at the grass. Then put it back and draw the grass with the wrap sampler instead of the clamp one, and look closely at the top edge of each tuft.
  3. Make the windows add light instead of tinting: set SrcBlend to D3D11_BLEND_ONE and DestBlend to D3D11_BLEND_ONE. Does the drawing order still matter?
Credit: this chapter follows LearnOpenGL's Blending 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.