05 - Instancing: A Thousand Cubes (one draw call)

The first four tutorials drew at most one piece of geometry per draw call. This one draws a thousand cubes from one indexed-instanced draw: a single vkCmdDrawIndexed(N_INDICES, N_INSTANCES, ...) fans the same 36 cube indices over 1000 instances, and each cube reads its world offset, colour tint, and breathing phase from a per-instance vertex attribute stream. The headline rails:

  • two vertex bindings, two input rates – binding 0 stays per-vertex (VkVertexInputRate.VERTEX), binding 1 is per-instance (VkVertexInputRate.INSTANCE). Same pipeline, two strides, two memory buffers, one draw call;

  • per-instance vertex attributes at @in @location = 2/3/4 (offset, colour, phase) – ordinary daslang stage inputs, the instancing is purely a host-side binding decision;

  • a static instance buffer built once at init from a 3D Lissajous-style sweep (7 azimuthal lobes against 3 elevation lobes) so no per-frame buffer rewrite is needed – the swarm animates entirely in the vertex shader by mixing the instance offset with cam.cam_time.w;

  • a wider draw budget than the cube tutorial – 1000 cubes × 36 indices × 4 attributes interleaved through one pipeline, still well under any practical limit on a desktop GPU.

Every line of the shader is daslang, lowered to SPIR-V at compile time by dasSpirv.

The clip above is the headless recording: 30 seconds, 30 fps, captured into an APNG and ffmpeg-muxed with a daStrudel music bed. The camera orbits the swarm once per 30 seconds; each cube breathes at its own per-instance phase so the swarm pulses like a slow chorus rather than a unison heartbeat. See skills/recording.md. To watch it live on your own GPU, run the windowed viewer (see See it live below).

The shaders

The vertex shader reads per-vertex (a_pos, a_normal) from binding 0 and per-instance (a_offset, a_color, a_phase) from binding 1. It applies a per-instance breathing scale, a per-instance Y rotation desynchronised by a_phase, the instance offset, and the camera transform. The fragment shader does a simple key-light + rim shade modulated by the per-instance colour – no texture, no sampler, the headline is per-instance attributes, not lighting. [vulkan_*_shader] synthesises instancing_vs_bind_uniform from var @uniform cam : Camera; there are no push constants this round because the per-instance buffer carries everything else.

module instancing_tut_shaders public

require vulkan/vulkan_boost public  // Device + DeviceMemory used by the generated bind_uniform helper
require vulkan/spirv_vulkan_shader public
require spirv/spirv_builtins public
require math

// ===== shared UBO =====

//! View+projection matrices + the camera world position (xyz) packed with time (w). Same shape as
//! tutorial 04's Camera, kept here so the tutorial reads independently.
struct Camera {
    view     : float4x4
    proj     : float4x4
    cam_time : float4     // xyz = camera world position, w = time in seconds
}
var @uniform @set = 0 @binding = 0 cam : Camera

// ===== per-vertex inputs (binding 0) =====

var @in @location = 0 a_pos    : float3
var @in @location = 1 a_normal : float3

// ===== per-instance inputs (binding 1) =====

//! Per-instance world offset. The host samples a 3D Lissajous curve into ``N_INSTANCES`` positions at
//! init; no per-frame buffer rewrite, the swarm is animated entirely from the vertex shader by
//! mixing the instance offset with time + per-instance phase.
var @in @location = 2 a_offset : float3
//! Per-instance RGB tint. Cycled through the hue circle three times across the swarm.
var @in @location = 3 a_color  : float3
//! Per-instance phase in [0,1), used to desynchronise the breathing animation.
var @in @location = 4 a_phase  : float

// ===== varyings =====

var @out @location = 0 v_world_pos    : float3
var @out @location = 1 v_world_normal : float3
var @out @location = 2 v_color        : float3

// ===== vertex shader =====

[vulkan_vertex_shader(name="instancing_vert_spv")]
def instancing_vs {
    let t = cam.cam_time.w
    // per-instance breathing: each cube scales between ~0.5 and ~0.9 at its own phase offset
    let breathe = 0.7 + 0.2 * sin(t * 1.5 + a_phase * 6.2832)
    let local = a_pos * breathe
    // gentle swarm-wide rotation around Y, slow enough to read at 30 fps
    let ang = t * 0.25 + a_phase * 6.2832
    let c = cos(ang)
    let s = sin(ang)
    let rotated = float3(c * local.x - s * local.z, local.y, s * local.x + c * local.z)
    let world = rotated + a_offset
    gl_Position = cam.proj * cam.view * float4(world, 1.0)
    v_world_pos = world
    // the normal is rotated by the same Y rotation; no per-instance scale shear so this is exact
    v_world_normal = float3(c * a_normal.x - s * a_normal.z, a_normal.y, s * a_normal.x + c * a_normal.z)
    v_color = a_color
}

// ===== fragment shader =====

var @in @location = 0 f_world_pos    : float3
var @in @location = 1 f_world_normal : float3
var @in @location = 2 f_color        : float3
var @out @location = 0 frag_color    : float4

[vulkan_fragment_shader(name="instancing_frag_spv")]
def instancing_fs {
    let n = normalize(f_world_normal)
    let v = normalize(cam.cam_time.xyz - f_world_pos)
    let l = normalize(float3(0.5, 1.0, 0.3))                // warm key light from above-front
    let key = max(dot(n, l), 0.0) * 0.7 + 0.3                // ambient floor 0.3
    let rim = pow(1.0 - max(dot(n, v), 0.0), 2.5)            // silhouette glow
    let lit = f_color * key + float3(0.25, 0.35, 0.55) * rim * 0.4
    frag_color = float4(lit, 1.0)
}

The render (headless)

Per-frame work splits into the same two halves as tutorial 04. update_instancing_uniforms rewrites the UBO with the new camera matrices + time. record_instancing_render_pass binds the pipeline + descriptor set, binds both vertex buffers in one ``vkCmdBindVertexBuffers`` call (binding 0 is per-vertex, binding 1 is per-instance), binds the index buffer, and issues vkCmdDrawIndexed(N_INDICES, N_INSTANCES, ...). The instance buffer is static, set up once in build_instancing_resources from the Lissajous sweep gen_instance_data produces.

def public update_instancing_uniforms(res : InstancingResources; device : Device; time, camera_t : float) {
    let camera_angle = camera_t * 2.0f * PI
    let camera_r = 14.0f
    let cam_x = camera_r * cos(camera_angle)
    let cam_z = camera_r * sin(camera_angle)
    // gentle vertical sway so the swarm shows depth from a varying angle
    let cam_y = sin(camera_angle * 2.0f) * 2.0f
    let cam_pos = float3(cam_x, cam_y, cam_z)
    cam.view = look_at_rh(cam_pos, float3(0.0f, 0.0f, 0.0f), float3(0.0f, 1.0f, 0.0f))
    cam.proj = perspective_vk(60.0f * PI / 180.0f, float(INSTANCE_W) / float(INSTANCE_H), 0.1f, 60.0f)
    cam.cam_time = float4(cam_pos.x, cam_pos.y, cam_pos.z, time)
    instancing_vs_bind_uniform(device, res.ubo.memory)
}

//! Record the instanced draw into the given command buffer. The render pass ends with the color
//! attachment in TRANSFER_SRC_OPTIMAL (boost default), so the windowed driver can blit straight
//! onto the swap target.
def public record_instancing_render_pass(res : InstancingResources; cmd : CommandBuffer) {
    var clears <- [clear_color(0.02f, 0.0f, 0.04f, 1.0f), clear_depth(1.0f)]
    record_render_pass(cmd, res.render_pass, res.framebuffer, full_area(INSTANCE_W, INSTANCE_H), clears) {
        cmd_bind_pipeline(cmd, res.pipeline)
        let sets <- [vk_value_to_boost(res.desc_set)]
        var no_dyn : array<uint>
        cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.pipe_layout, 0u, sets, no_dyn)
        // bind binding 0 (per-vertex) and binding 1 (per-instance) in one call
        let vbufs <- [weak_copy(res.vb.buffer), weak_copy(res.inst_buf.buffer)]
        var voffs <- [0ul, 0ul]
        cmd_bind_vertex_buffers(cmd, 0u, vbufs, voffs)
        cmd_bind_index_buffer(cmd, weak_copy(res.ib.buffer), 0ul, VkIndexType.UINT16)
        // the instancing call: 36 indices fanned over N_INSTANCES instances
        cmd_draw_indexed(cmd, uint(N_INDICES), uint(N_INSTANCES), 0u, 0, 0u)
    }
    delete clears
}

render_instancing_frame is what the headless path uses: call the two helpers inside a run_cmd_sync, then copy the colour attachment into the readback buffer and clone it out. render_instance_swarm is the one-shot wrapper the test calls; the recording driver builds the context once and calls render_instancing_frame in a loop.

def public render_instancing_frame(var ctx : InstancingContext; time, camera_t : float) : array<uint8> {
    update_instancing_uniforms(ctx.res, ctx.device, time, camera_t)
    var pixels : array<uint8>
    run_cmd_sync(ctx.device, ctx.pool, ctx.queue) $(cmd) {
        record_instancing_render_pass(ctx.res, cmd)
        copy_image_to_buffer(cmd, ctx.res.color.image, ctx.res.readback, INSTANCE_W, INSTANCE_H)
    }
    map_memory_to_array(ctx.device, ctx.res.readback.memory, ctx.res.buf_size) $(m) {
        pixels := m
    }
    return <- pixels
}

Self-verifying

The test is the CI regression gate (lavapipe in CI, real GPU locally). It asserts the swarm is scattered (frame corners are clear-colour background), dense (the central 400×400 region is at least a quarter cube hits), and palette-cycling (a warm-red instance and a cool-blue instance both exist somewhere in the central band, proving the hue rotation across the swarm reached the rendered pixels).

[test]
def test_instancing_oracle(t : T?) {
    var pixels <- render_instance_swarm(TEST_TIME, TEST_CAM)

    // four corners are background -- the swarm sits in the central volume, not edge-to-edge
    t |> success(bg(px(pixels, 8, 8)),                              "top-left corner is the clear colour")
    t |> success(bg(px(pixels, INSTANCE_W - 8, 8)),                 "top-right corner is the clear colour")
    t |> success(bg(px(pixels, 8, INSTANCE_H - 8)),                 "bottom-left corner is the clear colour")
    t |> success(bg(px(pixels, INSTANCE_W - 8, INSTANCE_H - 8)),    "bottom-right corner is the clear colour")

    // the central 400x400 sampled on a 40x40 grid is mostly cube hits (instance rendering worked)
    var hits = 0
    let cx = INSTANCE_W / 2
    let cy = INSTANCE_H / 2
    for (j in range(40)) {
        for (i in range(40)) {
            let dy = -200 + j * 10
            let dx = -200 + i * 10
            if (!bg(px(pixels, cx + dx, cy + dy))) {
                hits++
            }
        }
    }
    t |> success(hits >= 400, "central 400x400 box is at least a quarter cubes ({hits}/1600)")

    // palette: cycling the hue three times across the swarm puts both warm-red-leading and
    // cool-blue-leading pixels into the visible swarm. Cube-shaded so red-leading needs a strong
    // r, weak g, moderate b; blue-leading needs strong b, moderate g, weak r.
    var saw_red = false
    var saw_blue = false
    for (j in range(60)) {
        for (i in range(60)) {
            let y = cy - 200 + j * 6
            let x = cx - 200 + i * 6
            let p = px(pixels, x, y)
            if (p.x > 130 && p.y < 90 && p.z < 110) {
                saw_red = true
            }
            if (p.z > 130 && p.x < 110 && p.y < 130) {
                saw_blue = true
            }
        }
    }
    t |> success(saw_red,  "found a red-leading instance in the swarm (hue rotation present)")
    t |> success(saw_blue, "found a blue-leading instance in the swarm (hue rotation present)")

    delete pixels
}

See it live

window/show_instancing.das opens a GLFW window with a Vulkan swapchain and presents the swarm every frame. It owns its own instance (with surface extensions) + device (with VK_KHR_swapchain), then calls build_instancing_resources to share the offscreen render pass, framebuffer and graphics pipeline with the headless oracle. Each frame it runs update_instancing_uniforms + record_instancing_render_pass into the present command buffer, then blits the colour attachment onto the swapchain image. It needs a display and the glfw module, so it lives in a window/ subfolder that the tutorial’s CI gate skips (CI is headless and built without GLFW).

require glfw/glfw_boost
require vulkan
require vulkan/vulkan_boost
require vulkan/vulkan_window
require ../instancing_tut.das
require daslib/defer
require math

[export]
def main {   // nolint:STYLE038 - flat viewer lifecycle scaffold
    if (volkInitialize() != 0) {
        panic("no vulkan loader")
    }
    glfwInitVulkanLoader(vk_get_instance_proc_addr())
    if (glfwInit() == 0) {
        panic("can't init glfw")
    }
    defer() { glfwTerminate() }
    if (glfwVulkanSupported() == 0) {
        panic("glfw reports no vulkan support")
    }
    glfwWindowHint(int(GLFW_CLIENT_API), GLFW_NO_API)
    glfwWindowHint(int(GLFW_RESIZABLE), GLFW_TRUE)
    var window = glfwCreateWindow(INSTANCE_W, INSTANCE_H, "dasVulkan tutorial 05 - 1000 instanced cubes (blit)", null, null)
    if (window == null) {
        panic("can't create window")
    }
    defer() { glfwDestroyWindow(window) }

    var ext_count = 0u
    let glfw_exts = glfwGetRequiredInstanceExtensions(unsafe(addr(ext_count)))
    var inst_exts : array<string>
    inst_exts |> reserve(int(ext_count))
    for (i in range(int(ext_count))) {
        unsafe {
            inst_exts |> push(glfw_exts[i])
        }
    }
    var inscope instance <- create_instance("dasVulkan tutorial 05 (window)", make_api_version(1u, 3u, 0u), inst_exts)
    volkLoadInstance(boost_value_to_vk(instance))

    var inscope surface <- create_surface(instance, glfwGetNativeWindow(window), glfwGetNativeDisplay())

    let phys = select_physical_device(instance)
    let gfx = select_graphics_queue_family(phys)
    if (!queue_family_supports_present(phys, gfx, surface)) {
        panic("graphics queue family does not support presentation")
    }
    var inscope device <- create_device(phys, gfx, ["VK_KHR_swapchain"])
    volkLoadDevice(boost_value_to_vk(device))
    let queue = get_device_queue(device, gfx, 0u)

    var poolci : CommandPoolCreateInfo
    poolci.queueFamilyIndex = gfx
    var inscope pool <- create_command_pool(device, poolci)

    var inscope res <- build_instancing_resources(device, phys, queue, pool)
    var inscope swap <- create_swapchain(device, phys, surface, INSTANCE_W, INSTANCE_H)
    var inscope sync <- create_frame_sync(device)

    while (glfwWindowShouldClose(window) == 0) {
        glfwPollEvents()
        var fbw = 0
        var fbh = 0
        glfwGetFramebufferSize(window, fbw, fbh)
        if (fbw == 0 || fbh == 0) {
            glfwWaitEvents()
            continue
        }
        if (fbw != swap.width || fbh != swap.height) {
            vkDeviceWaitIdle(boost_value_to_vk(device))
            delete swap
            swap <- create_swapchain(device, phys, surface, fbw, fbh)   // nolint:PERF030 - deleted just above
        }
        let t = float(glfwGetTime())
        // 25-second camera orbit -- one full circle per 25s of wall clock.
        let raw_cam = t * 0.04f
        let camera_t = raw_cam - floor(raw_cam)
        update_instancing_uniforms(res, device, t, camera_t)

        let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
            record_instancing_render_pass(res, cmd)

            let none : VkAccessFlags
            var transfer_write : VkAccessFlags
            transfer_write.transfer_write = true
            var top : VkPipelineStageFlags
            top.top_of_pipe = true
            var xfer : VkPipelineStageFlags
            xfer.transfer = true
            var bottom : VkPipelineStageFlags
            bottom.bottom_of_pipe = true
            let dst = vk_value_to_boost(target)
            transition_image(cmd, dst, VkImageLayout.UNDEFINED, VkImageLayout.TRANSFER_DST_OPTIMAL,
                none, transfer_write, top, xfer)
            var region : VkImageBlit
            region.srcSubresource.aspectMask.color = true
            region.srcSubresource.layerCount = 1u
            region.srcOffsets[1].x = INSTANCE_W
            region.srcOffsets[1].y = INSTANCE_H
            region.srcOffsets[1].z = 1
            region.dstSubresource.aspectMask.color = true
            region.dstSubresource.layerCount = 1u
            region.dstOffsets[1].x = swap.width
            region.dstOffsets[1].y = swap.height
            region.dstOffsets[1].z = 1
            unsafe {
                vkCmdBlitImage(boost_value_to_vk(cmd), boost_value_to_vk(res.color.image), VkImageLayout.TRANSFER_SRC_OPTIMAL,
                    target, VkImageLayout.TRANSFER_DST_OPTIMAL, 1u, addr(region), VkFilter.LINEAR)
            }
            transition_image(cmd, dst, VkImageLayout.TRANSFER_DST_OPTIMAL, VkImageLayout.PRESENT_SRC_KHR,
                transfer_write, none, xfer, bottom)
        }
        if (!ok) {
            vkDeviceWaitIdle(boost_value_to_vk(device))
            delete swap
            swap <- create_swapchain(device, phys, surface, fbw, fbh)   // nolint:PERF030 - deleted just above
        }
    }
    vkDeviceWaitIdle(boost_value_to_vk(device))
}

Running it

# the CI pixel-oracle gate (lavapipe in CI, real GPU locally)
daslang -load_module <dasVulkan> <daslang>/dastest/dastest.das -- \
    --test <dasVulkan>/tutorials/05_instancing

# watch it live in a window (needs the glfw module + a display)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/05_instancing/window/show_instancing.das

# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/05_instancing/recording/record_instancing.das

Next

06 - Skybox: A Procedural Cubemap Sky wraps the scene in an environment — a 6-face cubemap rendered as a unit cube behind everything else. The shader uses a samplerCube (a daslang sampler type that lowers to OpTypeImage with Cube dim), and a depth-test-only / depth-write-disabled rail keeps the sky from clipping the geometry.