Coordinate Systems
In the last chapter a matrix moved our squares around, but the numbers we fed it were still screen numbers: 0.5 meant "a quarter of the window from the centre". That is how the pipeline wants positions at the end of the vertex shader. After the division by w, which we have not met properly yet, every visible vertex must lie in
The chain passes through several
Local space , also called object spaceWorld space View space , also called eye space or camera spaceClip space Screen space
This chapter walks a vertex through all of them, and by the end we are drawing real 3D cubes in perspective.
The global picture
Three matrices do most of the work: the
The green spaces are ours: we choose them and write the matrices between them. The grey ones are fixed by Direct3D.
- A model's vertices start out in
local coordinates , relative to the model's own origin. - The model matrix places the model in the scene, giving
world coordinates , relative to one origin shared by everything. - The view matrix re-expresses the world as seen from the camera:
view coordinates . - The projection matrix turns view coordinates into
clip coordinates , which decide what is visible, and is where perspective comes from. This is what the vertex shader returns asSV_Position. - The rasterizer divides by w to get NDC, and the
viewport transform turns NDC into pixel positions on the render target.
Why so many steps, when the three matrices could be multiplied into one? Because each space is the natural place for some kind of work. Editing a model is easiest in local space, placing things relative to each other is easiest in world space, and lighting, as we will see, can be done in world or view space depending on what it needs. Keeping the matrices separate keeps all of those spaces within reach.
Local space
Local space is where an object's vertices are written down. Someone building a crate in a modelling program puts its centre at the origin and its corners half a unit away, without knowing or caring where it will end up in a game. Every model starts like that. Our cube is the same: the 36 vertices of cube_vertices run from -0.5 to 0.5 on every axis, around (0, 0, 0). Those are local coordinates.
World space
If we drew every model straight from its local coordinates, they would all pile up at the origin. T * R * S order of the last chapter) that turns the object's local coordinates into world coordinates. Think of a house modelled too big and facing the wrong way: its model matrix scales it down, turns it to face the street, and moves it onto its plot. The matrices that moved our squares in the last chapter were model matrices in all but name.
View space
Clip space
At the end of the vertex shader the pipeline expects coordinates in
The region a projection matrix keeps is called the
Precisely, a clip-space position \((x, y, z, w)\) is visible when
\[-w \le x \le w, \qquad -w \le y \le w, \qquad 0 \le z \le w\]
The last condition is where Direct3D and OpenGL part ways: OpenGL keeps \(-w \le z \le w\). Direct3D's z is clipped against the near and far planes only while DepthClipEnable is set in the rasterizer state, as it is in all our lessons. After clipping, the rasterizer performs the
with x and y now in -1..1 and z in 0..1. This happens automatically after every vertex shader. Then the viewport takes over.
A projection matrix comes in one of two forms, each with its own shape of frustum:
Orthographic projection
An
It leaves w at 1, so the division by w changes nothing. math.rin builds one with
// Right-handed orthographic projection onto 0..1 depth.
m4_ortho: proc(left: f32, right: f32, bottom: f32, top: f32, z_near: f32, z_far: f32) -> mat4 = {
r: mat4 = m4_identity();
r.m[0] = 2.0 / (right - left);
r.m[3] = -(right + left) / (right - left);
r.m[5] = 2.0 / (top - bottom);
r.m[7] = -(top + bottom) / (top - bottom);
r.m[10] = 1.0 / (z_near - z_far);
r.m[11] = z_near / (z_near - z_far);
return r;
}
The first four arguments give the box's sides in view space, and the last two are the distances to the near and far planes, both positive, in front of the camera. Rows 0 and 1 scale and shift x and y so that left..right and bottom..top become -1..1. Row 2 is Direct3D's version: a point on the near plane, at \(z = -n\), comes out at depth 0, and one on the far plane, at \(z = -f\), at depth 1. GLM's glm::ortho maps the same planes to -1 and 1 instead.
Because size does not change with distance, orthographic projection looks flat. That is exactly what we want for 2D interfaces, technical drawings and, in later chapters, the shadow maps of a directional light, but it does not look like the world we see.
Perspective projection
Stand between two railway tracks and look along them: they seem to meet in the distance, and the sleepers grow smaller the further away they are. That is
The trick is in w. A perspective matrix copies the point's distance in front of the camera into w. The division by w then shrinks x and y in proportion to distance: a point twice as far away ends up twice as close to the centre of the screen. Our version is
// Right-handed perspective onto D3D's 0..1 depth range.
// (`near` and `far` are empty macros in windows.h, so the planes are z_near/z_far.)
m4_perspective: proc(fovy: f32, aspect: f32, z_near: f32, z_far: f32) -> mat4 = {
f: f32 = 1.0 / tanf(fovy * 0.5);
r: mat4 = {
.m = {
[0] = f / aspect,
[5] = f,
[10] = z_far / (z_near - z_far),
[11] = z_near * z_far / (z_near - z_far),
[14] = -1.0,
},
};
return r;
}
It takes four parameters, the same four as glm::perspective:
- fovy, the
field of view : the angle between the top and bottom of the frustum, in radians. 45 degrees looks natural; much wider gives the stretched look of a fish-eye lens. - aspect, the
aspect ratio : the viewport's width divided by its height. The frustum is made that much wider than it is tall, which does properly what our aspect scale did by hand in the last chapter. - z_near and z_far, the distances to the near and far planes. 0.1 and 100 are typical. Set the near plane too far out, say at 10, and everything closer than that is clipped, the familiar effect of walking into a wall in a game and seeing through it.
Written as a matrix, with \(c = 1 / \tan(\text{fovy} / 2)\) (the code's f), \(a\) the aspect, and \(n\) and \(f\) the near and far distances:
\[P_{D3D} = \begin{bmatrix} \frac{c}{a} & 0 & 0 & 0 \\ 0 & c & 0 & 0 \\ 0 & 0 & \frac{f}{n - f} & \frac{n f}{n - f} \\ 0 & 0 & -1 & 0 \end{bmatrix} \qquad P_{GL} = \begin{bmatrix} \frac{c}{a} & 0 & 0 & 0 \\ 0 & c & 0 & 0 \\ 0 & 0 & \frac{f + n}{n - f} & \frac{2 n f}{n - f} \\ 0 & 0 & -1 & 0 \end{bmatrix}\]The bottom row is what puts the distance into w: \(w = -z\), positive for anything in front of a camera that looks down -z. The first two rows are identical in both APIs. Only the two entries that make depth differ, and for good reason. Check ours at the near plane, \(z = -n\):
\[z_{clip} = \frac{f}{n - f}(-n) + \frac{n f}{n - f} = 0, \qquad w = n, \qquad z_{ndc} = 0\]and at the far plane, \(z = -f\):
\[z_{clip} = \frac{-f^2 + n f}{n - f} = f, \qquad w = f, \qquad z_{ndc} = 1\]
So the near plane maps to depth 0 and the far plane to 1, which is Direct3D's range. The GL matrix sends them to -1 and 1. Used unchanged with Direct3D, everything that came out below 0, which with a distant far plane is everything nearer than about twice the near distance, would be clipped, and half of the depth buffer's range would go unused. That is the one change projection needs, and it is why math.rin carries its own.
Side by side, the two projections look like this in Blender, perspective on the left and orthographic on the right. In the orthographic view the far end of the scene is drawn as large as the near end:
Putting it all together
One matrix per step, and a vertex goes from local space to clip space as
\[V_{clip} = M_{projection} \cdot M_{view} \cdot M_{model} \cdot V_{local}\]
read, as always, from right to left: model first, projection last. The vertex shader computes this and returns it as SV_Position; the rasterizer clips, divides by w, and applies the viewport.
The viewport transform
The
viewport: D3D11_VIEWPORT = {
.Width = cast(scr_width, f32),
.Height = cast(scr_height, f32),
.MaxDepth = 1.0,
};
and the rasterizer computes pixel coordinates from NDC like this:
\[X = (x_{ndc} + 1) \cdot \tfrac{\text{Width}}{2} + \text{TopLeftX}, \qquad Y = (1 - y_{ndc}) \cdot \tfrac{\text{Height}}{2} + \text{TopLeftY}, \qquad Z = \text{MinDepth} + z_{ndc} \cdot (\text{MaxDepth} - \text{MinDepth})\]Note the \(1 - y_{ndc}\). Direct3D's screen space starts at the top-left corner of the render target and its y grows downwards, like the rows of an image. NDC's y still points up, so the viewport flips it. OpenGL's window coordinates start at the bottom-left instead, which is one reason images and textures come out upside down when code moves between the two APIs. The last part maps depth onto MinDepth..MaxDepth, 0..1 here, which is what ends up in the depth buffer.
Going 3D
Time to use all of this. We need a model matrix, a view matrix and a projection matrix, and a shader that applies them. The shader first: the three matrices go into one constant buffer,
cbuffer object : register(b0)
{
float4x4 model;
float4x4 view;
float4x4 projection;
};
mirrored on the CPU by a struct with the same three fields in the same order:
object_constants: struct = {
model: mat4;
view: mat4;
projection: mat4;
}
and the vertex shader applies them, right to left, in nested
vs_out vs_main(vs_in v)
{
vs_out o;
o.position = mul(projection, mul(view, mul(model, float4(v.position, 1.0))));
o.uv = v.uv;
return o;
}
The shader file starts with #pragma pack_matrix(row_major), as every shader that reads our matrices does. Each float4x4 takes exactly four registers, so the buffer is 192 bytes with no padding to worry about.
Now the matrices. For the view matrix, remember that the camera always sits at the origin of view space. To see a model that sits at the world origin we have to step back from it, and stepping the camera back is the same thing as pushing the whole world away. Our world is right-handed: +x to the right, +y up and +z coming out of the screen towards us.
So "backwards" for the camera is +z, and the view matrix moves the world the opposite way, three units along -z:
c: object_constants = {
.view = m4_translate(v3(0.0, 0.0, -3.0)),
.projection = m4_perspective(radians(45.0), cast(scr_width, f32) / cast(scr_height, f32), 0.1, 100.0),
};
and the projection is a 45 degree perspective with the window's aspect ratio, from 0.1 to 100 units.
Before cubes, it is worth trying the smallest possible step. If the model matrix tilts the square from the last chapter back by 55 degrees about the x axis,
c.model = m4_rotate(radians(-55.0), v3(1.0, 0.0, 0.0));
the square looks as if it lies on a floor in front of us: tilted away, three units off, and narrowing towards its far edge. That is perspective doing its job. Our lesson goes straight on to cubes.
More 3D
A cube has six faces of two triangles each, so drawn without an index buffer it is 36 vertices. Ours carry a normal as well as a position and a texture coordinate, eight floats or 32 bytes each. The normals are for the lighting chapters; this lesson's shader reads them in vs_in but does not use them. One face looks like this:
// 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,
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, 0.0,
0.5, 0.5, 0.5, 1.0, 0.0, 0.0, 0.0, 0.0,
...
};
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 },
};
The texture coordinates have v growing downwards, Direct3D's way, so each face shows the crate the right way up. The cube is drawn with a plain
ID3D11DeviceContext_IASetVertexBuffers(context, 0, 1, cube_vb&, stride&, offset&);
...
ID3D11DeviceContext_Draw(context, 36, 0);
If you draw a spinning cube with only what we have so far, it looks wrong. Faces at the back show through faces at the front, and the cube seems to turn inside out as it rotates. The reason is that the output merger writes every pixel the pixel shader produces, in the order the triangles arrive. A back face listed after a front face simply paints over it. We need a way to keep the nearest surface at each pixel.
Z-buffer
That way is the
GLFW creates a depth buffer along with LearnOpenGL's window. Here we create it ourselves, like everything else: a texture with a depth format, and a
// A depth buffer the size of the window: 24 bits of depth, 8 of stencil.
depth_desc: D3D11_TEXTURE2D_DESC = {
.Width = cast(scr_width, UINT),
.Height = cast(scr_height, UINT),
.MipLevels = 1,
.ArraySize = 1,
.Format = DXGI_FORMAT.DXGI_FORMAT_D24_UNORM_S8_UINT,
.SampleDesc = { .Count = 1 },
.BindFlags = D3D11_BIND_DEPTH_STENCIL,
};
depth_texture: *ID3D11Texture2D = null;
depth_dsv: *ID3D11DepthStencilView = null;
ID3D11Device_CreateTexture2D(device, depth_desc&, null, depth_texture&);
ID3D11Device_CreateDepthStencilView(device, cast(depth_texture, *ID3D11Resource), null, depth_dsv&);
DXGI_FORMAT_D24_UNORM_S8_UINT packs 24 bits of depth and 8 bits of stencil into each pixel; the stencil part waits for Stencil testing. The bind flag D3D11_BIND_DEPTH_STENCIL says the texture will be used as a depth buffer, the role a render target view plays for colour.
Every frame we bind it next to the back buffer, and clear it along with the colour:
ID3D11DeviceContext_OMSetRenderTargets(context, 1, backbuffer_rtv&, depth_dsv);
ID3D11DeviceContext_RSSetViewports(context, 1, viewport&);
ID3D11DeviceContext_ClearRenderTargetView(context, backbuffer_rtv, clear_color[0]&);
ID3D11DeviceContext_ClearDepthStencilView(context, depth_dsv, D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0, 0);
Depth is cleared to 1.0, the far plane, so the first thing drawn at any pixel is always nearer and passes. Without the clear, last frame's depths would still be there, and this frame's cubes would be tested against where the cubes were a moment ago.
In OpenGL the depth test is off until glEnable(GL_DEPTH_TEST). In Direct3D it is part of the null there, as the earlier lessons did, is what turns it off. Depth testing creates depth-stencil states of its own to change the comparison.
More cubes!
One cube is a start. The scene in this lesson has ten, all identical, at different places in the world. Since they share their shape, they share one vertex buffer and one input layout; the only thing that differs between them is the model matrix. Here is where they go, in world space:
// world space positions of our cubes
cube_positions: [10]vec3 = {
{ .x = 0.0, .y = 0.0, .z = 0.0 },
{ .x = 2.0, .y = 5.0, .z = -15.0 },
{ .x = -1.5, .y = -2.2, .z = -2.5 },
...
{ .x = -1.3, .y = 1.0, .z = -1.5 },
};
and the loop that draws them, inside the render loop, after the view and projection have been set:
for (i: u32 = 0; i < 10; i += 1) {
angle: f32 = radians(20.0 * cast(i, f32));
if (i % 3 == 0) { angle += current_frame; }
c.model = m4_mul(m4_translate(cube_positions[i]), m4_rotate(angle, v3(1.0, 0.3, 0.5)));
update_buffer(context, constants, c&, sizeof(object_constants));
ID3D11DeviceContext_Draw(context, 36, 0);
}
Each cube is rotated about the same tilted axis, (1, 0.3, 0.5), by 20 degrees more than the one before, and then moved to its position: \(T \cdot R\) again, so it turns about its own centre. Every third cube, the first included, also adds the time to its angle and keeps turning. LearnOpenGL leaves that as an exercise; our lesson does it. The view and projection stay the same for all ten, but the whole constant buffer, all three matrices, is written again before each draw, since D3D11_MAP_WRITE_DISCARD gives us a fresh buffer every time and does not keep the previous contents.
Ten crates, near ones large and far ones small, each hiding what is behind it. The one at the origin fills the middle of the view, three units in front of the camera. The full program is here and its shaders here.
Additional resources
- OpenGL Projection Matrix: Song Ho Ahn's derivation of both projections, step by step. It derives the GL depth range; replace "-1" with "0" at the near plane and the same steps give ours.
- Getting started with the rasterizer stage: Microsoft on viewports and the viewport transform.
- Configuring depth-stencil functionality: depth buffers, views and depth-stencil states in Direct3D 11.
- LearnOpenGL's Coordinate Systems: the OpenGL version of this chapter.
Exercises
- Change the field of view and the aspect ratio passed to
m4_perspective . Try 90 and 20 degrees, and an aspect of 1. Explain each result in terms of the frustum. - Move the camera by changing the view matrix: translate the world along x and y as well as z, and add a rotation. Remember that the view matrix moves the world, not the camera.
- Pass
nullinstead of depth_dsv toOMSetRenderTargets and watch the cubes break up. Then put it back and stop clearing the depth buffer instead. Why does that go wrong differently? - Replace the perspective projection with
m4_ortho , keeping the window's aspect ratio (for examplem4_ortho(-4.0, 4.0, -2.25, 2.25, 0.1, 100.0)). Which cubes can you no longer tell apart by distance?