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 the first three numbers of this lesson's colours:

colors colors_constants <<1.0, 0.5, 0.31>, 0.0, <1.0, 1.0, 1.0>, 0.0>

full red, half green, and a little under a third of blue. (The rest of that line is the light's colour, white, and two floats of padding we come to below.)

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.) SSE has the same operation, mulps, four floats at a time, but the lighting itself all happens in the shaders; the CPU side only hands over the colours.

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 it lives in includes/learn/cube.asm and every lesson from here on includes it unchanged. 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:

align 4
cube_vertices label byte
              ; right (+x)
              real4   0.5,  -0.5,   0.5,     1.0,   0.0,   0.0,   0.0, 1.0
              real4   0.5,  -0.5,  -0.5,     1.0,   0.0,   0.0,   1.0, 1.0
              real4   0.5,   0.5,  -0.5,     1.0,   0.0,   0.0,   1.0, 0.0
; ...
cube_stride equ 32
cube_vertices_size equ 36 * cube_stride
.erre ($ - cube_vertices) eq cube_vertices_size

Thirty-six vertices of eight floats, 32 bytes each. The .erre line is a check the assembler makes: if the table were a row short or long, $ - cube_vertices (how far we got since the label) would differ from the size we claim, and assembling would stop with an error. The input layout names the three attributes and where each starts within a vertex, in bytes:

cube_semantic_position byte "POSITION", 0
cube_semantic_normal byte "NORMAL", 0
cube_semantic_texcoord byte "TEXCOORD", 0
align 8
cube_layout D3D11_INPUT_ELEMENT_DESC <offset cube_semantic_position, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0>
            D3D11_INPUT_ELEMENT_DESC <offset cube_semantic_normal, 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0>
            D3D11_INPUT_ELEMENT_DESC <offset cube_semantic_texcoord, 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24, D3D11_INPUT_PER_VERTEX_DATA, 0>
cube_layout_count equ 3

The fifth field of each element is the byte offset: 0, 12 and 24. When the buffer is created and bound, we give the stride, 32 bytes per vertex:

invoke create_buffer, D3D11_BIND_VERTEX_BUFFER, ADDR cube_vertices, cube_vertices_size
mov cube_vb, rax
stride dword cube_stride
offset_ dword 0
vcall context, ID3D11DeviceContext_IASetVertexBuffers, 0, 1, ADDR cube_vb, ADDR stride, ADDR offset_

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 cube_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 assembly struct that mirrors it, register for register. Every lesson from here to the end of the site uses it, so it lives in includes/learn/transforms.asm:

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 real4 ?
transforms ends

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, and sizeof transforms comes out at 272, seventeen registers. (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, projection and eye position once per frame:

; rcx: &transforms, rdx: &camera
;
; view from the camera, projection with its zoom as the field of view (0.1 to 100 units), and
; where the eye is.
align 16
transforms_from_camera proc t:qword, cam:qword
    mov rcx, t
    lea rcx, [rcx].transforms.view
    invoke camera_view, rcx, cam
    mov rcx, t
    lea rcx, [rcx].transforms.projection
    invoke camera_projection, rcx, cam
    mov rcx, t
    mov rdx, cam
    v3load xmm0, [rdx].camera.position
    v3store [rcx].transforms.view_position, xmm0
    ret
transforms_from_camera endp

and let a small helper rewrite the buffer before each draw:

; rcx: constant buffer, rdx: &transforms, r8: model matrix
;
; The model matrix and its normal matrix, sent; then the 36 vertices of the cube already bound.
; HLSL has no inverse(), so the normal matrix is worked out here, for every draw.
align 16
draw_cube proc buffer:qword, t:qword, model:qword
    mov rcx, t
    mov rdx, model
    for row, <0, 16, 32, 48>
        movups xmm0, [rdx + row]
        movups [rcx + row], xmm0
    endm
    lea rcx, [rcx].transforms.normal_matrix
    invoke m4_normal_matrix, rcx, model
    invoke update_buffer, buffer, t, sizeof transforms
    vcall context, ID3D11DeviceContext_Draw, 36, 0
    ret
draw_cube endp
for row, <0, 16, 32, 48> is not a loop the program runs: it is a loop the assembler runs, writing the two lines inside out four times with row replaced by each number. The result is four 16-byte copies, one matrix row each, with no counter and no branch. model is the first member of transforms, so the copy goes to offset 0 of t. movups rather than movaps because nothing promises the matrices are 16-byte aligned.

update_buffer, from gpu.asm, maps the buffer with D3D11_MAP_WRITE_DISCARD, copies the struct in with mem_copy 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 assembly side of the buffer has a padding float after each colour:

; Mirrors `cbuffer colors` in colors.hlsl: a float3 fills a register only with the float after it.
colors_constants struct
    object_color vec3 <>
    pad0 real4 ?
    light_color vec3 <>
    pad1 real4 ?
colors_constants ends

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 assembly struct in the same place and work out where the padding goes. MASM lays a struct out exactly as written, with no padding of its own, so every gap is one you put there.

The initializer we started the chapter with fills the struct in the same order: the coral vec3, a zero for pad0, the white vec3, a zero for pad1. Angle brackets nest, one pair per struct.

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, and each constant buffer is made at the size of the struct it will carry:

invoke shader_create, ADDR lighting_shader, "src/2.lighting/1.colors/colors.hlsl", ADDR cube_layout, cube_layout_count
invoke shader_create, ADDR lamp_shader, "src/2.lighting/1.colors/light_cube.hlsl", ADDR cube_layout, cube_layout_count
invoke create_constant_buffer, sizeof transforms
mov transforms_buffer, rax
invoke create_constant_buffer, sizeof colors_constants
mov colors_buffer, rax
invoke create_constant_buffer, sizeof vec4
mov lamp_buffer, rax

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 gpu.asm binds a buffer to the same slot of the vertex, geometry and pixel stages at once:

; rcx: register (b0, b1, ...), rdx: buffer
;
; The same buffer in the same register for the vertex, geometry and pixel stages.
align 16
bind_constants proc slot:qword, buffer:qword
    vcall context, ID3D11DeviceContext_VSSetConstantBuffers, slot, 1, ADDR buffer
    vcall context, ID3D11DeviceContext_GSSetConstantBuffers, slot, 1, ADDR buffer
    vcall context, ID3D11DeviceContext_PSSetConstantBuffers, slot, 1, ADDR buffer
    ret
bind_constants endp

The Set methods take an array of buffers. Ours is an array of one: the procedure's own argument, which ADDR buffer points at where it was saved in the frame.

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 <1.2, 1.0, 2.0>

Each frame window_begin clears the window, we bind the cube and the transforms, 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; m4_translate_scale builds translate times scale in one go, so the scale happens first:

invoke window_begin, floats(0.1, 0.1, 0.1, 1.0)
vcall context, ID3D11DeviceContext_IASetPrimitiveTopology, D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST
vcall context, ID3D11DeviceContext_IASetVertexBuffers, 0, 1, ADDR cube_vb, ADDR stride, ADDR offset_
invoke bind_constants, 0, transforms_buffer
invoke transforms_from_camera, ADDR t, ADDR cam

; the object: coral, under white light
invoke update_buffer, colors_buffer, ADDR colors, sizeof colors_constants
invoke shader_use, ADDR lighting_shader
invoke bind_constants, 1, colors_buffer
invoke m4_identity, ADDR model
invoke draw_cube, transforms_buffer, ADDR t, ADDR model

; the lamp: a small white cube where the light is
invoke update_buffer, lamp_buffer, ADDR lamp_color, sizeof vec4
invoke shader_use, ADDR lamp_shader
invoke bind_constants, 1, lamp_buffer
lea rcx, model
lea rdx, light_position
movf xmm2, 0.2
call m4_translate_scale
invoke draw_cube, transforms_buffer, ADDR t, ADDR model
m4_translate_scale takes a float as its third argument, so the call is made by hand: the two addresses in rcx and rdx, and 0.2 in xmm2, the register of the third position. movf puts the constant in the .const section and loads it from there, since an SSE register cannot take a float straight from the instruction.

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 the light colour in colors to pure green, <0.0, 1.0, 0.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 copying colors.light_color into lamp_color before the lamp's buffer is updated, instead of leaving it white. (v3load and v3store move a vec3; the alpha can stay 1.)
  3. Move pad0 in colors_constants so that it sits after light_color, next to pad1, and reorder the initializer to match. 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 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.