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 always decodes to four channels, RGBA, and our textures are always R8G8B8A8, 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 1x1 square standing on y = 0, facing +z: grass and windows
quad_vertices: [48]f32 = {
    -0.5, 0.0, 0.0,   0.0, 0.0, 1.0,   0.0, 1.0,
     0.5, 0.0, 0.0,   0.0, 0.0, 1.0,   1.0, 1.0,
     0.5, 1.0, 0.0,   0.0, 0.0, 1.0,   1.0, 0.0,
    -0.5, 0.0, 0.0,   0.0, 0.0, 1.0,   0.0, 1.0,
     0.5, 1.0, 0.0,   0.0, 0.0, 1.0,   1.0, 0.0,
    -0.5, 1.0, 0.0,   0.0, 0.0, 1.0,   0.0, 0.0,
};

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: [4]vec3 = {
    { .x = -2.2, .y = -0.5, .z = 0.4 },
    { .x = 2.6, .y = -0.5, .z = 1.2 },
    { .x = 0.6, .y = -0.5, .z = 1.4 },
    { .x = -0.6, .y = -0.5, .z = -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 and bind it into s0 for the grass and the windows:

// A second sampler, clamping at the edges, for the grass and windows.
clamp_desc: D3D11_SAMPLER_DESC = sampler_desc;
clamp_desc.AddressU = D3D11_TEXTURE_ADDRESS_MODE.D3D11_TEXTURE_ADDRESS_CLAMP;
clamp_desc.AddressV = D3D11_TEXTURE_ADDRESS_MODE.D3D11_TEXTURE_ADDRESS_CLAMP;
clamp_sampler: *ID3D11SamplerState = null;
ID3D11Device_CreateSamplerState(device, clamp_desc&, clamp_sampler&);
ID3D11DeviceContext_PSSetSamplers(context, 0, 1, clamp_sampler&);
bind_texture(context, 0, grass);
for (i: u32 = 0; i < 4; i += 1) { draw_vertices(context, transforms_buffer, t&, quad_vb, 6, m4_translate(grass_positions[i])); }

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.

// out = src * src_alpha + dst * (1 - src_alpha)
blend_desc: D3D11_BLEND_DESC = {
    .RenderTarget = {
        {
            .BlendEnable = 1,
            .SrcBlend = D3D11_BLEND.D3D11_BLEND_SRC_ALPHA,
            .DestBlend = D3D11_BLEND.D3D11_BLEND_INV_SRC_ALPHA,
            .BlendOp = D3D11_BLEND_OP.D3D11_BLEND_OP_ADD,
            .SrcBlendAlpha = D3D11_BLEND.D3D11_BLEND_ONE,
            .DestBlendAlpha = D3D11_BLEND.D3D11_BLEND_INV_SRC_ALPHA,
            .BlendOpAlpha = D3D11_BLEND_OP.D3D11_BLEND_OP_ADD,
            .RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL,
        },
    },
};
alpha_blend: *ID3D11BlendState = null;
ID3D11Device_CreateBlendState(device, blend_desc&, alpha_blend&);

The settings are per render target, which is why they sit in RenderTarget[0]; a pass that draws into several targets at once can blend each one differently. 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: [5]vec3 = {
    { .x = -1.5, .y = -0.5, .z = -0.48 },
    { .x = 1.5, .y = -0.5, .z = 0.51 },
    { .x = 0.0, .y = -0.5, .z = 0.7 },
    { .x = -0.3, .y = -0.5, .z = -2.3 },
    { .x = 0.5, .y = -0.5, .z = -0.6 },
};

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

ID3D11DeviceContext_OMSetBlendState(context, alpha_blend, null, 0xffffffff);
bind_texture(context, 0, glass);
for (i: u32 = 0; i < 5; i += 1) { draw_vertices(context, transforms_buffer, t&, quad_vb, 6, m4_translate(window_positions[order[i]])); }
ID3D11DeviceContext_OMSetBlendState(context, null, null, 0xffffffff);

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); all ones means 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 order[i] in the loop. 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:

// Farthest first. Five windows: a selection sort is plenty.
order: [5]u32 = { 0, 1, 2, 3, 4 };
for (i: u32 = 0; i < 5; i += 1) {
    for (j: u32 = i + 1; j < 5; j += 1) {
        di: f32 = v3_length(v3_sub(window_positions[order[i]], cam.position));
        dj: f32 = v3_length(v3_sub(window_positions[order[j]], cam.position));
        if (dj > di) {
            swap: u32 = order[i];
            order[i] = order[j];
            order[j] = swap;
        }
    }
}

Each slot i ends up holding the farthest of the windows not yet placed. Sorting indices rather than positions means the positions array is never touched, and a map would only add allocations for no gain. 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 (use window_positions[i] instead of the sorted index) 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 rin. 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.