Colors

We have been throwing colours around since the first triangle, without ever saying what a colour is. Before we start lighting things, that deserves a proper answer, because lighting is, in the end, nothing but arithmetic on colours. In this chapter we pin down what an object's colour means, and then build the small scene that every lighting chapter after this one works in.

Light in the real world comes in a continuous spread of wavelengths, far more than any screen can show. A monitor gets by with three: it mixes red, green and blue light at different strengths, and our eyes read the mix as a single colour. So in a program a colour is three numbers, one per channel, each running from 0 (none of that channel) to 1 (all of it). We have written colours as float4 in HLSL already; the fourth number, alpha, is about transparency, and in lighting we mostly ignore it. A warm coral colour, for example, is

.object_color = v3(1.0, 0.5, 0.31),

full red, half green, and a little under a third of blue.

Colours are what gets reflected

Here is the part that makes lighting work. The colour we see when we look at an object is not something the object "has" on its own. It is the part of the incoming light that the object sends back towards us. Sunlight looks white because it carries a bit of every visible colour. When it hits a coral toy, the toy swallows most of the blue, a fair amount of the green, and throws back nearly all of the red. What reaches our eye is that leftover mix, and we call it coral.

White light hitting a coral object: most colours are absorbed, the reflected mix is coral (LearnOpenGL's diagram)

LearnOpenGL's diagram above shows the idea: white light arrives with every colour in it, and only the coral part leaves. (The real physics is richer than this, and the PBR chapters come back to it, but this picture carries us a long way.)

That gives us a precise meaning for "the colour of an object": for each of red, green and blue, the fraction of that channel's light the object reflects. And it gives us the rule for computing what we see. Take the light's colour and the object's colour, and multiply them channel by channel:

\[ \text{result} = (L_r \cdot O_r,\; L_g \cdot O_g,\; L_b \cdot O_b) \]

With white light, \(L = (1, 1, 1)\), the result is the object's colour unchanged, \((1.0, 0.5, 0.31)\): coral under white light looks coral, as it should. Now shine a pure green light on the same toy, \(L = (0, 1, 0)\). There is no red or blue arriving, so none can be reflected, and of the green that does arrive the toy sends back half:

light  = (0.0, 1.0, 0.0)
object = (1.0, 0.5, 0.31)
result = (0.0, 0.5, 0.0)     a dark green

The coral toy has turned dark green. Nothing about the object changed; it simply had no red light to give back. One more, with a murky olive light:

light  = (0.33, 0.42, 0.18)
object = (1.0,  0.5,  0.31)
result = (0.33, 0.21, 0.06)  a dull brown

This channel-by-channel product is the core of every lighting model we will write. In HLSL it costs nothing to say: multiplying two float3s with * multiplies them component by component. (That is not the same as mul, which is the matrix product we use for transforms.)

A lighting scene

To experiment with light we need two things in the world: an object for the light to fall on, and something that shows where the light is. We use the cube from the previous chapters for both. A big one sits at the origin as our subject; a small one, scaled down to a fifth, marks the light's position. On its own a light is just a point in space with no shape at all, so this little light cube (LearnOpenGL calls it the lamp) is there only so we can see where the light is coming from.

The vertex data

The cube's vertex array already carries everything the lighting chapters will need, so we set it up once here and leave it alone. Every vertex has a position, a normal (a direction pointing straight out of the face; the next chapter explains it) and a texture coordinate, eight floats in all:

// a unit cube: position, normal, texture coordinate; counter-clockwise from outside
cube_vertices: [288]f32 = {
    // right (+x)
     0.5, -0.5,  0.5,    1.0,  0.0,  0.0,   0.0, 1.0,
     0.5, -0.5, -0.5,    1.0,  0.0,  0.0,   1.0, 1.0,
     0.5,  0.5, -0.5,    1.0,  0.0,  0.0,   1.0, 0.0,
...
};

Thirty-six vertices of eight floats is 288. The input layout names the three attributes and where each starts within a vertex, in bytes:

vertex_layout: [3]D3D11_INPUT_ELEMENT_DESC = {
    { .SemanticName = "POSITION", .Format = DXGI_FORMAT.DXGI_FORMAT_R32G32B32_FLOAT, .AlignedByteOffset = 0 },
    { .SemanticName = "NORMAL", .Format = DXGI_FORMAT.DXGI_FORMAT_R32G32B32_FLOAT, .AlignedByteOffset = 12 },
    { .SemanticName = "TEXCOORD", .Format = DXGI_FORMAT.DXGI_FORMAT_R32G32_FLOAT, .AlignedByteOffset = 24 },
};

and when the buffer is bound we give the stride, 32 bytes per vertex:

cube_vb: *ID3D11Buffer = create_buffer(device, D3D11_BIND_VERTEX_BUFFER, cube_vertices[0]&, sizeof(cube_vertices));
stride: UINT = 32;
offset: UINT = 0;

LearnOpenGL gives the light cube a VAO of its own, so that changing the container's attribute setup later cannot disturb the lamp. In Direct3D that question does not come up. There is no VAO: the input layout belongs to the shader (our shader_create builds one against each vertex shader), and the vertex buffer is bound separately, at draw time. Both cubes draw from the same buffer, each through its own shader's layout.

The light cube's vertex shader, as we will see, reads only POSITION. Creating an input layout with more elements than the shader uses is fine: Direct3D checks that every input the shader does read is supplied, and simply ignores the rest. So one vertex_layout serves both shaders.

The transforms, shared by every shader

Each object needs its model matrix, and everybody needs the view and projection. In the earlier chapters a constant buffer carried these three. The lighting lessons extend it with two more members that the coming chapters need, a normal_matrix and the camera's position, view_position. Here is the HLSL side, which every shader in the lesson declares identically:

cbuffer transforms : register(b0)
{
    float4x4 model;
    float4x4 normal_matrix;
    float4x4 view;
    float4x4 projection;
    float3 view_position;
};

and the rin struct that mirrors it, register for register:

// Mirrors `cbuffer transforms` in the shaders, register for register.
transforms: struct = {
    model: mat4;
    normal_matrix: mat4;     // inverse transpose of model, for normals
    view: mat4;
    projection: mat4;
    view_position: vec3;     // the eye, in world space
    pad: f32;
}

The trailing pad is there because HLSL thinks of a constant buffer as a row of 16-byte registers, and a buffer's size must be a whole number of them. A float3 fills twelve bytes of its register; we pad the last four ourselves so the two sides agree on the size. (The normal matrix is computed, though the colours lesson does not use it yet; Basic Lighting explains it.)

Since the model matrix changes for every object while the rest stays the same within a frame, the lessons fill the view and projection once per frame and let a small helper rewrite the buffer before each draw:

// Draws the 36 vertices of the bound cube with `model`.
draw_cube: proc(context: *ID3D11DeviceContext, buffer: *ID3D11Buffer, t: *transforms, model: mat4) -> void = {
    t[0].model = model;
    t[0].normal_matrix = m4_normal_matrix(model);
    update_buffer(context, buffer, t, sizeof(transforms));
    ID3D11DeviceContext_Draw(context, 36, 0);
}

update_buffer maps the buffer with D3D11_MAP_WRITE_DISCARD, copies the struct in and unmaps it. Discarding means the driver hands us fresh memory each time, so the copy for the second cube never has to wait for the GPU to finish drawing the first.

The lighting shader

The cube we light gets colors.hlsl. Its vertex shader already does more than this chapter needs: it works out the vertex's world position and normal and passes them on, ready for the next chapter. For now the pixel shader ignores them. It needs only two colours, which arrive in a second constant buffer, in register b1:

cbuffer colors : register(b1)
{
    float3 object_color;
    float3 light_color;
};

float4 ps_main(vs_out p) : SV_Target
{
    return float4(light_color * object_color, 1.0);
}

That is the rule from the start of the chapter, in one line. The rin side of the buffer has a padding float after each colour:

colors_constants: struct = {
    object_color: vec3;
    pad0: f32;
    light_color: vec3;
    pad1: f32;
}

Why after object_color too? HLSL's packing rule says a value may not straddle a register boundary. object_color occupies bytes 0 to 11 of the first register; light_color needs twelve bytes, which do not fit in the four left over, so HLSL starts it at byte 16. Our struct must leave the same gap, or the light colour would be read from the wrong place. Getting this wrong produces no error at all, just strange colours, so it is worth checking every time a constant buffer changes.

A float3 followed by a float share one register; a float3 followed by another float3 do not. Whenever you add a member to a cbuffer, add it to the rin struct in the same place and work out where the padding goes.

The light cube's shader

In the coming chapters the lighting shader will change a lot: ambient light, highlights, falloff. None of that should touch the light cube, which ought to stay bright whatever we do, since it stands for the light itself. So it has a shader file of its own, light_cube.hlsl, that is never edited:

cbuffer lamp : register(b1)
{
    float4 lamp_color;
};

float4 vs_main(float3 position : POSITION) : SV_Position
{
    return mul(projection, mul(view, mul(model, float4(position, 1.0))));
}

float4 ps_main() : SV_Target
{
    return lamp_color;
}

It declares the same transforms buffer at b0 (left out above) and ignores everything but the model, view and projection. Where LearnOpenGL's lamp shader hard-codes white, ours reads its colour from a buffer of its own, also at b1. That costs one float4, sixteen bytes, and later chapters use it to tint each light cube to match its light.

Both shaders are compiled from their files at startup, with the same layout:

lighting_shader: shader = shader_create(device, filesystem_path("src/2.lighting/1.colors/colors.hlsl"), vertex_layout);
lamp_shader: shader = shader_create(device, filesystem_path("src/2.lighting/1.colors/light_cube.hlsl"), vertex_layout);
lamp_buffer: *ID3D11Buffer = create_constant_buffer(device, 16);

Registers are shared

Notice that two different buffers, colors and lamp, both live in register b1. That is fine, because a register is just a slot on the pipeline: whatever buffer we bind there before a draw is what that draw's shaders see. The bind_constants helper from the earlier chapters binds a buffer to the same slot of the vertex, geometry and pixel stages at once:

// The same buffer in the same register for the vertex, geometry and pixel stages.
bind_constants: proc(context: *ID3D11DeviceContext, slot: UINT, buffer: *ID3D11Buffer) -> void = {
    buffers: [1]*ID3D11Buffer = { buffer };
    ID3D11DeviceContext_VSSetConstantBuffers(context, slot, 1, buffers[0]&);
    ID3D11DeviceContext_GSSetConstantBuffers(context, slot, 1, buffers[0]&);
    ID3D11DeviceContext_PSSetConstantBuffers(context, slot, 1, buffers[0]&);
}

The transforms go to b0 once per frame and stay there for every draw; the lighting lessons keep their own constants at b1 and up. This takes the place of OpenGL's glUniform calls: instead of setting named variables one by one on a program object, we fill a struct and hand the whole thing to a slot.

Drawing it

The light sits at a fixed point in the world, up and to the right of the cube and a little towards the camera:

light_position: vec3 = v3(1.2, 1.0, 2.0);

Each frame we fill in the view and projection, then draw the coral cube with the lighting shader and the small cube with the lamp shader. The light cube's model matrix moves it to the light's position and shrinks it to 0.2 of its size; read right to left, the scale happens first:

t: transforms = {
    .view = camera_view(cam&),
    .projection = m4_perspective(radians(cam.zoom), cast(scr_width, f32) / cast(scr_height, f32), 0.1, 100.0),
    .view_position = cam.position,
};

colors: colors_constants = {
    .object_color = v3(1.0, 0.5, 0.31),
    .light_color = v3(1.0, 1.0, 1.0),
};
update_buffer(context, colors_buffer, colors&, sizeof(colors_constants));
shader_use(context, lighting_shader&);
bind_constants(context, 1, colors_buffer);
draw_cube(context, transforms_buffer, t&, m4_identity());

lamp_color: vec4 = v4(1.0, 1.0, 1.0, 1.0);
update_buffer(context, lamp_buffer, lamp_color&, 16);
shader_use(context, lamp_shader&);
bind_constants(context, 1, lamp_buffer);
draw_cube(context, transforms_buffer, t&, m4_mul(m4_translate(light_position), m4_scale(v3(0.2, 0.2, 0.2))));

The camera starts in front of the cube and a little to its left, turned slightly to look at it, so the light cube appears near the right edge of the window. If everything is in place, you get this:

A flat coral cube in the middle of the window, and a small white cube at the top right marking the light

Admittedly not much to look at: a flat coral shape and a white square. Every face of the cube has exactly the same colour, because nothing in our shader yet cares which way a face points or where the light is. That changes in the very next chapter. As before, WASD moves the camera, space and shift move it up and down, and holding the right mouse button lets you look around.

The full program is here, the lighting shader here and the light cube's shader here.

Additional resources

Exercises

  1. Change light_color to pure green, (0, 1, 0), and check that the cube turns the dark green worked out above. Then try the olive light (0.33, 0.42, 0.18).
  2. Make the light cube show the light's colour, by setting lamp_color from colors.light_color instead of white.
  3. Move pad0 in colors_constants so that it sits after light_color, next to pad1. The struct keeps its size, so nothing fails, but the cube changes colour. Work out from the packing rule which bytes the shader now reads as light_color, and explain the colour you see. (Put the padding back afterwards.)
Credit: this chapter follows LearnOpenGL's Colors 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.