All tutorials Mighty Professional
Build a Game Engine · Rendering: into 3D

Going 3D: Perspective, Depth & Meshes

Load your first 3D model and it comes out flat, upside down, inside-out, or with its back faces showing through the front. Each of those is one missing piece. The 2D renderer already has a device, a swapchain, a pipeline, and a draw loop. Going 3D adds four things to it: a perspective projection in place of the orthographic one, a mandatory depth buffer, vertices with a Z and a normal, and a model matrix per object. This module wires each into the existing Vulkan scaffold and puts a glTF mesh on screen, in C++ and Rust.

Time~55 min LevelSenior PrereqsHelpful, not required: Your First Triangle in Vulkan (the scaffold), 3D Math (the MVP chain), The GPU & the Graphics Pipeline (the depth test), and Textures, Samplers & Materials (descriptor sets). StackC++ (Vulkan/glm) · Rust (ash/glam)
◂ Build a Game Engine Phase 7 · Rendering: into 3D Next · PBR Materials & Lighting ▸
What you'll be able to do by the end

01From 2D to 3D

A sprite renderer draws flat quads in a fixed order; a 3D scene has objects at every distance, overlapping in any order. Four changes turn the sprite renderer into a 3D one. The instance, device, swapchain, and synchronization stay as they are.

  1. Orthographic → perspective. The projection now produces a non-unit w, and the GPU's perspective divide gives foreshortening: distant things shrink.
  2. The becomes mandatory. In 2D, back-to-front draw order sufficed (the 2D renderer's painter's approach); in 3D, arbitrary overlap needs per-pixel depth (the depth test).
  3. Vertices gain Z and a normal. Position is a vec3; a normal rides along for lighting later.
  4. A model matrix per object. 2D pushed one view-projection; 3D needs an M per object, which forces the question of how to feed many matrices to the GPU.

Worked example

The 2D sprite vertex was 20 bytes. The 3D vertex on this page is a 12-byte position, a 12-byte normal and an 8-byte UV: 32 bytes. A cube with flat-shaded faces needs 24 of them, 768 bytes, plus 36 16-bit indices, 72 bytes. At 1920 × 1080 the new depth buffer adds 1920 × 1080 × 4 = 8.3 MB of 32-bit depth.

02Perspective projection

In a 3D game, a crate 20 m away should look half the size of the same crate at 10 m. The matrix maps a view frustum (defined by vertical FOV, aspect, near, and far) into clip space such that the later divide by w produces foreshortening. The 3D Math tutorial derives the matrix; here are the result and the Vulkan-specific deltas that bite.

After the divide by w, a view-space point (x, y, z), with the camera looking down −z, lands at:

eq. 1 · projected position xndc = (f ÷ aspect) × x ÷ (−z),   yndc = f × y ÷ (−z)

In words: scale the point by the lens's focal factor, squeeze x for the screen's aspect, and divide both by the distance in front of the camera. Twice as far means half the size on screen. The projection matrix stores the distance in w so the GPU's divide does this. Hover any term to see what it stands for.

Worked example

A 60° vertical field of view gives f = 1 ÷ tan 30° = 1.732, and the aspect is 16:9 = 1.778. A point at view position (2, 1, −10) lands at x = 1.732 ÷ 1.778 × 2 ÷ 10 = 0.195 and y = 1.732 × 1 ÷ 10 = 0.173. Move it to z = −20 and it lands at (0.097, 0.087), half as far from the center: half the size. Vulkan's Y-down framebuffer then needs y negated, which is the flip in the callout below.

It's the Y-flip, not "Vulkan is left-handed"

Three independent things get conflated. Vulkan's NDC is right-handed; its depth range is 0 to 1 (OpenGL is −1 to 1); and its framebuffer is Y-down. The Y-down mapping is separate from both the depth range and your world handedness[4]. So GLM_FORCE_DEPTH_ZERO_TO_ONE fixes the depth range but not Y: you still flip proj[1][1] *= -1 (or use a negative viewport height). In Rust, glam::Mat4::perspective_rh already targets 0 to 1 and perspective_rh_gl is the −1 to 1 one; picking the wrong one silently breaks depth[3].

Reversed-Z is the precision fix, and it's orthogonal to the 0-to-1 range

A plain [0,1] depth buffer wastes precision: the 1/z mapping bunches values near the camera, so far geometry z-fights. Reversed-Z (map near→1, far→0) with a float depth buffer nearly cancels that nonlinearity; Reed's conclusion is "in any perspective projection situation, just use a floating-point depth buffer with reversed-Z"[2]. It's the additional near↔far swap on top of the 0-to-1 range you already have, plus flipping the depth compare to GREATER (the mirror of LESS) and clearing depth to 0 (the GPU Pipeline tutorial's framing). It needs a float format; on a UNORM buffer the math doesn't help.

The widget shows the frustum from the side and the projected result next to it:

The left panel is the camera's frustum seen from the side, the middle panel what the camera sees, and the bottom strip the depth buffer's steps. Drag FOV, Near and Far, then switch to orthographic. The frustum is a truncated pyramid under perspective and a box under orthographic. Hollow objects fall outside it and are clipped. The projected panel has one column per depth: perspective divides by depth, so far columns shrink toward the center, and widening the FOV shrinks everything; orthographic keeps every column the same size. The bottom strip slices the stored depth into equal steps. Under perspective the steps crowd near the camera and stretch toward the far plane, and a smaller near plane makes the far steps coarser still: the 1/z distribution that reversed-Z with a float buffer counteracts.
Perspective + look-at (the Vulkan Y-flip)
// define BEFORE including glm: GLM_FORCE_RADIANS, GLM_FORCE_DEPTH_ZERO_TO_ONE
glm::mat4 projection = glm::perspective(glm::radians(60.0f), aspect, 0.1f, 1000.0f);
projection[1][1] *= -1.0f;                       // flip Y for Vulkan's Y-down NDC (depth macro does NOT do this)
glm::mat4 view = glm::lookAt(cameraPos, cameraTarget, glm::vec3(0,1,0));
glm::mat4 clipFromModel = projection * view * model;   // P * V * M, column vectors
// glam: perspective_rh already targets 0..1 depth (perspective_rh_gl is the -1..1 one)
let mut projection = Mat4::perspective_rh(60.0_f32.to_radians(), aspect, 0.1, 1000.0);
projection.y_axis.y *= -1.0;                          // flip Y for Vulkan's Y-down NDC
let view = Mat4::look_at_rh(camera_pos, camera_target, Vec3::Y);
let clip_from_model = projection * view * model;      // P * V * M

03The camera & MVP

The player moves the camera, and every object in the world has to move the opposite way on screen. The view matrix is the inverse of the camera's world transform; lookAt(eye, center, up) builds it directly. Two cameras cover most needs: an orbit camera (spherical coordinates around a target) and a free-fly camera (position plus yaw/pitch, WASD and mouse).

Worked example

An orbit camera 5 m from a target at the origin, at 45° yaw and 30° pitch (y up, yaw measured from +z), sits at eye = (5 cos 30° sin 45°, 5 sin 30°, 5 cos 30° cos 45°) = (3.06, 2.5, 3.06). lookAt(eye, origin, up) then builds the view matrix that moves the world so this point becomes the camera's origin, looking down its −z axis at the target.

The full transform, for column vectors, reads right to left: model, then view, then projection (the 3D Math convention).

eq. 2 · the MVP chain clip = P · V · M · position

In words: multiply the vertex by model first (place it in the world), then view (move it into the camera's frame), then projection (apply perspective). With column vectors the matrices sit on the left and apply right-to-left, so the chain is written in the reverse of the order it runs. P and V are the same for every vertex in a frame; M changes per object, which is the next problem. Hover any symbol to see what it stands for.

(Row-vector libraries such as DirectXMath write it position · M · V · P; follow whichever convention your math library uses.)

Need a refresher on "read right to left"?

With column vectors (the convention glm and glam use), a point is a column and matrices stack on its left: P · V · M · position. Matrix multiplication is associative, so P · V · M · position equals P · (V · (M · position)): the matrix nearest the vector acts first. The vector meets M first, then V, then P, even though the line reads P-V-M left to right.

The flip is purely the convention. Row-vector libraries (DirectXMath's XMMATRIX) write the same transform as position · M · V · P, left to right in application order. Same result, mirror-image notation. The 3D Math tutorial derives both.

04The depth buffer

Without a depth buffer, whatever is drawn last ends up on top, so a wall drawn after a character covers it even when the character is in front. This is the densest delta over the color-only triangle. Four steps: pick a supported depth format, create the depth image and view, enable depth in the pipeline, and wire the depth attachment into dynamic rendering[1].

Clear depth every frame, and mind the two booleans

The depth attachment needs loadOp = CLEAR with depth 1.0 (or 0.0 for reversed-Z). Skip it and the test compares against stale values (LOAD keeps last frame's depth; DONT_CARE leaves the contents undefined), so objects flicker or vanish. Opaque geometry also needs both depthTestEnable (the comparison happens) and depthWriteEnable (passing fragments write their depth); writes only happen while the test is enabled. Test-on/write-off is a real mode: it's how blended transparents are hidden by opaques without hiding each other, while they're sorted back to front like the 2D renderer's sprites.

Two shapes overlap; the cube is nearer but drawn first:

The readout gives each shape's depth, cube 0.39 and sphere 0.61 at the start (smaller is nearer). Untick the depth test, then the per-frame clear, then drag Depth gap toward 0. Depth test off: the sphere, drawn last, paints over the nearer cube (the painter's failure that forced depth in 3D). On: the cube occludes. Uncheck "clear depth" and every pixel keeps the depth an earlier frame wrote there; wherever a shape has already been, its own old depth ties with it, LESS rejects it, and the shapes erase themselves until only their leading edges draw. Close the gap until the depths are less than 0.025 apart and the overlap shimmers: the two depths round to the same stored value, and per-pixel rounding picks the winner. Reversed-Z shrinks that rounding error at distance; exactly coplanar surfaces fight regardless.
Wire the depth attachment into the pipeline & the render pass (Rust · ash)
// pipeline creation: add the depth format alongside the color format (the delta over the triangle)
let color_formats = [swapchain_format];                   // named so the borrow outlives this statement
let mut rendering = vk::PipelineRenderingCreateInfo::default()
    .color_attachment_formats(&color_formats)
    .depth_attachment_format(depth_format);              // NEW
let depth_stencil = vk::PipelineDepthStencilStateCreateInfo::default()
    .depth_test_enable(true).depth_write_enable(true)
    .depth_compare_op(vk::CompareOp::LESS);              // GREATER for reversed-Z

// record time: a depth attachment beside the color one, cleared each frame
let depth_attachment = vk::RenderingAttachmentInfo::default()
    .image_view(depth_image_view)
    .image_layout(vk::ImageLayout::DEPTH_ATTACHMENT_OPTIMAL)
    .load_op(vk::AttachmentLoadOp::CLEAR)                // MUST clear, or garbage occlusion
    .store_op(vk::AttachmentStoreOp::DONT_CARE)
    .clear_value(vk::ClearValue { depth_stencil: vk::ClearDepthStencilValue { depth: 1.0, stencil: 0 } });  // 0.0 for reversed-Z
let rendering_info = vk::RenderingInfo::default()
    .color_attachments(&color_attachments)
    .depth_attachment(&depth_attachment);

05The 3D vertex

Lighting later needs to know which way each point on the surface faces, and textures need to know which part of the image goes where. The vertex grows to position (vec3), normal (vec3), and UV (vec2). The vertex input description gains two attributes:

The vertex and its input description
struct Vertex { glm::vec3 position; glm::vec3 normal; glm::vec2 uv; };
VkVertexInputBindingDescription binding{ 0, sizeof(Vertex), VK_VERTEX_INPUT_RATE_VERTEX };
VkVertexInputAttributeDescription attrs[3] = {
    { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, offsetof(Vertex, position) },
    { 1, 0, VK_FORMAT_R32G32B32_SFLOAT, offsetof(Vertex, normal)   },
    { 2, 0, VK_FORMAT_R32G32_SFLOAT,    offsetof(Vertex, uv)       },
};
#[repr(C)]
struct Vertex { position: [f32; 3], normal: [f32; 3], uv: [f32; 2] }
let binding = vk::VertexInputBindingDescription::default()
    .binding(0).stride(size_of::<Vertex>() as u32).input_rate(vk::VertexInputRate::VERTEX);
let attrs = [
    vk::VertexInputAttributeDescription::default().location(0).format(vk::Format::R32G32B32_SFLOAT).offset(0),
    vk::VertexInputAttributeDescription::default().location(1).format(vk::Format::R32G32B32_SFLOAT).offset(12),
    vk::VertexInputAttributeDescription::default().location(2).format(vk::Format::R32G32_SFLOAT).offset(24),
];

The normal needs the inverse-transpose of the model's upper-3×3 under non-uniform scale (the 3D Math tutorial covers why). The lighting that uses it lands in the next module; here the vertex just carries it.

Worked example

The attribute offsets in the listing follow from the sizes: position at 0, normal at 12, UV at 24, and a 32-byte stride. For the normal: stretch a model 2× along x and take a surface whose normal is (0.707, 0.707, 0). Transforming the normal by the model matrix gives (1.414, 0.707), normalized (0.894, 0.447), which is no longer perpendicular to the stretched surface. The inverse-transpose scales x by 0.5 instead: (0.354, 0.707), normalized (0.447, 0.894). A surface tangent (0.707, −0.707) stretched to (1.414, −0.707) is perpendicular to the second (0.354 × 1.414 − 0.707 × 0.707 = 0) but not the first.

06Loading a mesh

Hand-typed cubes run out fast; real geometry comes from an artist's tool, through a file format your loader has to understand. Prove the pipeline with a hardcoded cube (24 vertices for per-face normals, 36 indices in an index buffer), then load real geometry. The standard interchange format is glTF 2.0, "the JPEG of 3D": a runtime-ready, royalty-free format that shipping engines and viewers load[5].

glTF's data model: accessors → bufferViews → buffers

A buffer is a blob of bytes (a .bin or the binary chunk of a .glb); a bufferView is an (offset, length, stride) slice of it; an accessor types that slice (component type, element type like VEC3, count). Positions, normals, and indices are each accessors[6]. glTF is right-handed, +Y up, +Z forward, meters, with counter-clockwise front faces (the winding the culler reads). Three traps: indices may be unsigned byte, short, or int (handle the component type); attributes can be interleaved (respect byteStride) or sparse (apply the sparse substitutions); and importing into a left-handed engine mirrors an axis, which reverses the winding. glTF is a delivery format: artists author in their own tools and export to it.

Worked example

A cube with per-face normals needs 24 vertices, not 8, because each corner belongs to three faces that point different ways. In glTF its positions are an accessor of type VEC3, component type 5126 (float), count 24, reading a bufferView of 24 × 12 = 288 bytes. The normals can be a second accessor whose bufferView starts at byte 288. The indices are an accessor of component type 5123 (unsigned short), count 36: 72 bytes. A loader that assumed 32-bit indices here would read 18 garbage indices.

Two loaders do the parsing: cgltf, a single-file C library, and the gltf crate in Rust[7].

Load a glTF mesh
let (document, buffers, _images) = gltf::import("model.gltf")?;   // resolves .bin / GLB
for mesh in document.meshes() {
    for primitive in mesh.primitives() {
        let reader = primitive.reader(|b| Some(&buffers[b.index()]));
        let positions: Vec<[f32; 3]> = reader.read_positions().unwrap().collect();
        let normals:   Vec<[f32; 3]> = reader.read_normals().unwrap().collect();
        let indices:   Vec<u32>     = reader.read_indices().unwrap().into_u32().collect();
        // normals and indices are optional in glTF: a real loader handles None instead of unwrapping
        // upload positions/normals (read_tex_coords(0) gives uvs) into a vertex buffer, indices into an index buffer
    }
}
// cgltf: single-file C99 loader
cgltf_options options = {0};
cgltf_data* data = NULL;
cgltf_parse_file(&options, "model.gltf", &data);
cgltf_load_buffers(&options, data, "model.gltf");   // reads .bin, decodes data URIs
// walk data->meshes[i].primitives[j].attributes[k].data (accessor) -> buffer_view -> buffer
cgltf_free(data);

07Per-object data

A scene with a thousand props needs a thousand model matrices on the GPU each frame. One object: push the model matrix (or premultiplied MVP) as a push constant: a 64-byte mat4 fits in the 128-byte guaranteed minimum (the 2D renderer did this). Many objects need more room, and there's a ladder.

std140 alignment, the silent garble

In std140 a vec3 aligns to 16 bytes (its size is still 12), a mat4 is 64, and array elements and struct members round up to 16[8]. A C or Rust struct with a float followed by a vec3, or two vec3s in a row, won't match the shader's block: the CPU places the vec3 at offset 4 (or 12), std140 at 16. That's the classic garbled-transform bug. (A vec3 followed by a float happens to match, since the float fills the 4-byte tail.) Pad explicitly, or avoid vec3 in uniform blocks. UBOs default to std140; std430 is the SSBO and push-constant default. For a dynamic UBO, query minUniformBufferOffsetAlignment instead of hard-coding it: the spec allows up to 256 bytes, and some GPUs require that.

Worked example

A uniform block { float intensity; vec3 color; } has intensity at offset 0 and color at offset 16 in std140, 32 bytes in all. The matching-looking C struct puts color at offset 4, so the shader reads color from 12 bytes past where the CPU wrote it. For per-object data, 1,000 objects with a 64-byte matrix each take 64,000 bytes packed in an SSBO, but 256,000 bytes in a dynamic UBO on a GPU whose offset alignment is 256, because each object's slot is rounded up to 256 bytes.

08Culling & winding

A closed mesh like a character has as many triangles facing away from the camera as toward it, and drawing the hidden half is wasted work. Back-face culling skips triangles facing away: cullMode = BACK plus a frontFace winding. glTF declares counter-clockwise front faces. How that interacts with Vulkan's Y-down framebuffer decides whether the model renders correctly, inside-out, or not at all.

Any vertical mirror reverses the winding

Vulkan decides facing from the triangle's winding in framebuffer coordinates, the orientation it has on screen. Without a Y-flip, an OpenGL-style projection draws the image upside down, and that mirror also reverses the on-screen winding: glTF's CCW triangles land clockwise, cullMode = BACK discards the faces you meant to keep, and the model renders inside-out or vanishes[11]. Flipping Y (proj[1][1] *= -1 or a negative viewport height; both produce the same framebuffer coordinates) turns the image upright again, so CCW data stays CCW on screen and frontFace = COUNTER_CLOCKWISE is right, as in OpenGL. Inside-out after the flip means the flip and frontFace disagree: the flip applied twice (matrix and viewport), or a CLOCKWISE left over from a triangle authored directly in Vulkan's Y-down clip space. An engine that renders offscreen without the flip, as AnKi does, sets the opposite frontFace on purpose[4].

Worked example

Winding is the sign of the triangle's area. For corners (0, 0), (1, 0), (0, 1) with y up, (x1 − x0)(y2 − y0) − (x2 − x0)(y1 − y0) = 1 × 1 − 0 × 0 = 1: positive, counter-clockwise. Mirror y and the third corner becomes (0, −1): 1 × (−1) − 0 = −1, clockwise. Every triangle in the mesh flips the same way, so a culler set for counter-clockwise fronts now discards the whole visible side.

09The scene graph

A character holds a sword. When the character turns, the sword has to turn with it, without anyone updating the sword's position by hand. Objects rarely live in world space directly; they hang off a hierarchy, the . Each node has a local transform; its world transform is computed by traversing parents before children. glTF's node tree maps straight onto this; Sascha Willems's glTF samples build each node's matrix by walking up its parents[10].

eq. 3 · node world transform world = parent.world · local

In words: a node's place in the world is its parent's world transform composed with its own local one. Because parent.world is on the left, the parent must be computed first; walk the tree top-down (parents before children) and every node sees a finished parent. Flip the two factors and the child orbits the world origin instead of its parent, the classic bug the widget shows.

Worked example

A sun sits at world position (4, 0). A planet's local transform is "move 2 along x, then rotate by the orbit angle", and at 90° that puts it 2 units above its parent. Correct order, parent.world · local: the planet lands at (4, 0) + (0, 2) = (4, 2), next to the sun. Wrong order, local · parent: the sun's offset is applied first and the rotation after, so the planet lands at the 90° rotation of (6, 0), which is (0, 6): orbiting the world origin at radius 6.

The optimization is dirty flags: recompute a node's world transform only when it or an ancestor moved. The widget shows a parent driving its children, and the cost of recomputing everything versus just the dirty subtree:

The sun is the static node to the right of the world origin; the readout counts the transforms recomputed this frame. Toggle dirty flags, then the multiply order. The sun sits still, offset from the world origin. One planet orbits it and carries its moon along; the other two planets are static. Each node's world transform is parent.world · local. With dirty flags off, all 7 nodes recompute every frame; on, only the moving planet and its moon (2). Flip the multiply order to local · parent and the children swing around the world origin instead of the sun: the child's orbit rotation now applies to the parent's offset too, the classic scene-graph bug.

Wrong answers, and why: Vulkan's NDC is right-handed (the symptoms are the Y-down framebuffer and the 0-to-1 range, not handedness or format); and an inside-out model is a winding and culling mismatch, not a depth or index-width issue.

10Pitfalls

"Vulkan is left-handed"It's right-handed. The symptoms are the Y-down flip and the 0-to-1 depth range, both separate.
Depth macro fixes YGLM_FORCE_DEPTH_ZERO_TO_ONE fixes the range, not Y. Still flip proj[1][1].
Forgot to clear depthGarbage occlusion. loadOp = CLEAR every frame.
Model inside-outfrontFace disagrees with the on-screen winding. With the Y-flip, glTF's CCW data wants COUNTER_CLOCKWISE; without it, CLOCKWISE.
Garbled transform in a UBOstd140: vec3 aligns to 16. Pad the CPU struct to match the shader block.
Reversed-Z on a UNORM bufferIt needs a float depth format; the math relies on float's distribution.
Assuming a depth formatQuery support; D32_SFLOAT is common, but check, don't assume.
Opaques don't occlude each otherdepthWriteEnable is off, or depthTestEnable is (writes are disabled while the test is off). Opaque passes need both on.

11What's next

There's a lit-ready mesh on screen with correct depth. Next is making it look good: PBR Materials & Lighting uses the normal the vertex now carries and the metallic-roughness textures to shade the surface physically, then shadows, then deferred rendering. The full 3D path is on the series hub.

  1. Khronos. Vulkan Tutorial, "Depth Buffering." docs.vulkan.org. The format query, the depth image/view, the depth-stencil state, and the dynamic-rendering depth attachment with clear.
  2. Nathan Reed. "Depth Precision Visualized." reedbeta.com. The 1/z precision problem and reversed-Z with a float depth buffer.
  3. glam (Rust). docs.rs/glam. perspective_rh (0 to 1) vs perspective_rh_gl (−1 to 1), and look_at_rh. (Cite by API; glam moves fast across minor versions.)
  4. AnKi 3D Engine. "Vulkan's coordinate system." anki3d.org. Right-handed NDC with Y down, 0-to-1 depth, the negative viewport height, and the opposite frontFace AnKi uses when rendering offscreen without it.
  5. The Khronos Group. glTF 2.0 Specification. registry.khronos.org. The runtime interchange format: right-handed, +Y up, meters, CCW front faces, and the buffer/bufferView/accessor model.
  6. The Khronos Group. glTF Tutorial, "Buffers, BufferViews, Accessors." github.khronos.org. The three-level data model, explained plainly.
  7. cgltf (github.com/jkuhlmann/cgltf, single-file C) and the gltf crate (docs.rs/gltf, Rust). The loaders: cgltf_parse_file/cgltf_load_buffers and gltf::import + the primitive reader.
  8. The Khronos Group. Vulkan guide, "Shader Memory Layout" and "Descriptor Dynamic Offset." docs.vulkan.org. std140 alignment (vec3→16, mat4→64) and dynamic-UBO offset alignment.
  9. The Khronos Group. Vulkan Specification, VkPipelineRenderingCreateInfo. registry.khronos.org. The depthAttachmentFormat for dynamic rendering, which must match the bound depth view.
  10. Sascha Willems. Vulkan glTF scene rendering and Vulkan-glTF-PBR. github.com/SaschaWillems. The production reference for a glTF-to-engine loader and per-object data: node matrices are composed by walking up the parent chain and pushed per node.
  11. Sascha Willems. "Flipping the Vulkan viewport." 2019. saschawillems.de. OpenGL-style CCW data renders upside down with its front faces culled in Vulkan; a negative viewport height fixes both with unchanged vertex data and shaders.

See also