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.