Skip to content

Guides Scene

Devin edited this page Apr 27, 2026 · 1 revision

Guides · Scene

What you'll build

A small scene with multiple meshes, each with its own model matrix, sharing one pipeline. Plus a clean shutdown order that scales — five meshes or five hundred, the rule doesn't change.

Prerequisites

The why

A renderer with one mesh hides the question of "who owns what". As soon as you add a second mesh sharing the same pipeline, you need a small struct that bundles geometry + transform + draw call. Call it SceneObject. Iterate over a std::vector<SceneObject> and the per-frame loop stays the same shape regardless of N.

Code

#include "VCK.h"
using namespace VCK;

struct SceneObject {
    VulkanMesh mesh;          // owns the GPU buffers
    Mat4       transform;     // model matrix; updated by app code
};

std::vector<SceneObject> scene;

VulkanMesh is move-only. std::vector over it works as long as you don't shrink-resize after Upload; build the scene up front, drive draws by index.

Building the scene

auto AddCube = [&](Vec3 pos, float size = 1.0f)
{
    Primitives::Mesh cpu = Primitives::Cube(size);

    struct V { Vec3 p; Vec3 n; };
    static_assert(sizeof(V) == 24, "tightly packed");

    std::vector<V> verts(cpu.positions.size());
    for (std::size_t i = 0; i < verts.size(); ++i)
        verts[i] = { cpu.positions[i], cpu.normals[i] };

    SceneObject obj;
    obj.transform = Translate(pos);
    obj.mesh.Upload(device, command,
                    verts.data(),
                    static_cast<VkDeviceSize>(sizeof(V) * verts.size()),
                    static_cast<uint32_t>(verts.size()),
                    cpu.indices.data(),
                    static_cast<uint32_t>(cpu.indices.size()));
    scene.push_back(std::move(obj));
};

AddCube({-2, 0,  0});
AddCube({ 0, 0,  0}, 1.5f);
AddCube({ 2, 0,  0});
AddCube({ 0, 0, -3}, 0.7f);

Primitives::Cube returns CPU-side positions/normals/uvs/indices — VCK never owns the resulting Mesh (rule 22). Pack the streams the way your shader expects (here: interleaved {position, normal}), then upload.

VulkanMesh::Upload's second argument is the total byte count of the vertex buffer, not the per-vertex stride. Pass sizeof(V) * verts.size() — the example file calls this out because it's the obvious thing to get wrong.

The frame loop

void DrawScene(Frame& f)
{
    vkCmdBindPipeline(f.PrimaryCmd(), VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline.GetPipeline());

    // Per-frame UBO (camera) is bound once; all objects share it.
    VkDescriptorSet camSet = camera.GetSet(sync.GetCurrentFrameIndex());
    vkCmdBindDescriptorSets(f.PrimaryCmd(), VK_PIPELINE_BIND_POINT_GRAPHICS,
                            pipeline.GetPipelineLayout(), 0, 1, &camSet, 0, nullptr);

    for (SceneObject& obj : scene)
    {
        pc.Set("model", obj.transform);
        pc.Apply(f.PrimaryCmd(), pipeline.GetPipelineLayout(), VK_SHADER_STAGE_VERTEX_BIT);
        obj.mesh.RecordDraw(f.PrimaryCmd());
    }
}

One pipeline bind, one descriptor bind, N pushes + draws. Most renderers stay this shape until they need bucket sorting by material — at which point you sort scene by pipeline / texture and bind only when the bucket changes.

If a draw call fans out to many submission buffers (compute prepasses, async transfers), use f.Submissions().QueueGraphics(cb, info) for each and let the GpuSubmissionBatcher collapse them into one vkQueueSubmit.

Shutdown order

This is the rule that breaks in messy codebases. State it explicitly (rule 3):

void Shutdown()
{
    vkDeviceWaitIdle(device.GetDevice());        // exactly once, here

    scheduler.Shutdown();                         // borrows command + sync

    for (SceneObject& obj : scene) obj.mesh.Shutdown();
    scene.clear();

    framebuffers.Shutdown();
    // ... any other expansion objects (textures, depth, descriptor pools) ...

    sync.Shutdown();
    command.Shutdown();
    pipeline.Shutdown();
    swapchain.Shutdown();
    device.Shutdown();
    context.Shutdown();

    window.Destroy();
}

Strict reverse of init. vkDeviceWaitIdle is the only blocking wait that's correct outside of OneTimeCommand and the scheduler's own primitives — and only here, at shutdown (rule 4).

If you forget the order, validation will tell you which VkDevice was destroyed while still owning a VkBuffer. Don't silence those messages; they're correct.

What's next

VCK · Vulkan Core Kit

Getting Started

Guides

Reference

More


Single source of truth for the full API surface is the doc block at the top of VCK.h.

Clone this wiki locally