07 - Particles: A Compute-Driven Swarm

The first six tutorials drew geometry the host fed in: vertices and indices uploaded once, animated via uniforms and push constants. This one hands the vertex stream itself to a compute shader: one VkBuffer (created with USAGE_STORAGE_BUFFER | USAGE_VERTEX_BUFFER) holds the particle array, the compute pipeline rewrites it every frame via 1/r² gravity, then a single pipeline barrier hands the same buffer to the graphics pipeline as a per-vertex stream. The headline rails:

  • ``@ssbo`` – a SPIR-V StorageBuffer-class block holding an array<Particle>. The compute shader reads + writes through particles[i].field chained access chains; dasSpirv lowers them to a pair of OpAccessChain operations (struct-element then field-of-element);

  • ``USAGE_STORAGE_BUFFER | USAGE_VERTEX_BUFFER`` on one ``VkBuffer`` – the same bytes are bound to compute as an SSBO (vkCmdBindDescriptorSets) and to graphics as a vertex buffer (vkCmdBindVertexBuffers). The Particle struct’s std430 layout (pos@0, life@12, vel@16, stride 32) is matched by the vertex attribute offsets;

  • A per-frame compute → graphics barriervkCmdPipelineBarrier with srcStageMask=COMPUTE_SHADER, dstStageMask=VERTEX_INPUT, srcAccessMask=SHADER_WRITE, dstAccessMask=VERTEX_ATTRIBUTE_READ, scoped to the particle buffer. The canonical compute → graphics handoff; without it the graphics pipeline can race the compute and read stale values;

  • ``POINT_LIST`` topology + ``gl_PointSize`` – one screen-space splat per particle, PARTICLE_PX pixels wide. The vertex shader writes gl_PointSize (BuiltIn PointSize Output) and the host enables the largePoints physical-device feature so sizes > 1 take effect; the graphics pipeline is built inline (v3d hard-codes TRIANGLE_LIST) and uses VkPrimitiveTopology.POINT_LIST with no cull. No billboard math, no clip-space orientation pass against the projection-matrix Y-flip.

  • Pretty point sprites via ``gl_PointCoord`` – the fragment shader reads gl_PointCoord (BuiltIn PointCoord Input), the UV inside the rasterised point primitive ([0, 1]^2 with (0.5, 0.5) at the centre). Distance from centre drives a circular discard() mask + a cubic falloff for the core/halo glow – no sprite texture upload needed.

  • Additive blending + depth-test-without-write – the pipeline runs srcColor + dstColor with both factors set to ONE so overlapping splats accumulate into bright hot spots, and depth-write is disabled so particle-to-particle ordering doesn’t punch holes. The fragment outputs premultiplied colour with alpha = intensity (consistent across colour + alpha channels).

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 1024 particles swirl around the central attractor; the colour is mapped to instantaneous speed (cool teal at the slow end, hot magenta at the fast end). See skills/recording.md. To watch the swarm live on your own GPU, run the windowed viewer (see See it live below).

The shaders

The compute shader integrates the gravity-toward-origin force across all N_PARTICLES particles per dispatch (one invocation per particle, local_size_x = 64). The vertex shader projects each particle’s position to clip space; the fragment shader maps speed to the cool-teal → hot-magenta palette. [vulkan_*_shader] synthesises particles_vs_bind_uniform from the graphics shader’s @uniform and particles_compute_push_constants from the compute shader’s @push_constant.

module particles_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 particle struct =====

//! std430 layout: pos@0, life@12, vel@16, pad@28; stride 32 bytes. The pad keeps the struct's vec3
//! alignment (16 bytes) honest -- without it the stride would round up to 32 anyway, but writing it
//! explicitly makes the host-side initial-state generator's offsets impossible to miss.
struct Particle {
    pos  : float3
    life : float
    vel  : float3
    pad  : float
}

// ===== compute shader =====

//! Time + dt feed the explicit-Euler integration. dt = wall-clock-per-frame so the swarm advances
//! at a constant rate regardless of GPU speed; time isn't used in the integrator but is kept for a
//! future re-spawn / noise driver.
struct ComputePush {
    time : float
    dt   : float
}
var @push_constant cpc : ComputePush

var @ssbo @binding = 0 particles : array<Particle>

[vulkan_compute_shader(local_size_x=64, name="particles_compute_spv")]
def particles_compute {
    let i = gl_GlobalInvocationID.x
    let p_pos = particles[i].pos
    let p_vel = particles[i].vel
    // central attractor: gravity-like 1/r^2 pull toward the origin, softened by 0.05 so particles
    // passing close to the origin don't blow up. The orbit isn't strictly Keplerian (explicit Euler
    // drifts) but the trajectories stay bounded and lively for any practical recording length.
    let r2 = dot(p_pos, p_pos)
    let r_mag = sqrt(r2 + 0.05)
    let inv_r3 = 1.0 / (r_mag * r_mag * r_mag)
    let accel = -p_pos * inv_r3 * 1.8
    let new_vel = p_vel + accel * cpc.dt
    let new_pos = p_pos + new_vel * cpc.dt
    particles[i].vel = new_vel
    particles[i].pos = new_pos
    // life is just a colour driver -- it stays untouched here, the host seeded it once.
}

// ===== vertex shader -- point sprite per particle =====

//! View + projection. POINT_LIST topology makes each particle one GL point, rasterised as a screen-space
//! splat ``PARTICLE_PX`` pixels wide. ``gl_PointSize`` is a vertex-stage Output builtin (BuiltIn PointSize)
//! read only under POINT_LIST; values > 1 need the ``largePoints`` feature, which the host enables.
struct Camera {
    view : float4x4
    proj : float4x4
}
var @uniform @set = 0 @binding = 0 cam : Camera

//! Screen-space point size in pixels. 10 px reads cleanly at 768x768 and is well under every
//! desktop GPU's pointSizeRange max (lavapipe advertises [1, 64]).
let public PARTICLE_PX = 10.0

// per-instance attributes (binding 0, INPUT_RATE_INSTANCE) -- the SAME bytes the compute shader
// wrote to the SSBO, re-interpreted as vertex inputs with stride 32 + matching offsets.
var @in @location = 0 a_pos  : float3
var @in @location = 1 a_life : float
var @in @location = 2 a_vel  : float3

var @out @location = 0 v_speed   : float           // |vel|, drives colour
var @out @location = 1 v_life    : float           // 0..1, drives intensity

[vulkan_vertex_shader(name="particles_vert_spv")]
def particles_vs {
    gl_Position = cam.proj * cam.view * float4(a_pos, 1.0)
    gl_PointSize = PARTICLE_PX                       // BuiltIn PointSize write -> rasteriser splat width
    v_speed = sqrt(dot(a_vel, a_vel))
    v_life = a_life
}

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

var @in @location = 0 f_speed : float
var @in @location = 1 f_life  : float
var @out @location = 0 frag_color : float4

[vulkan_fragment_shader(name="particles_frag_spv")]
def particles_fs {
    // gl_PointCoord is the UV inside the rasterised point primitive, [0,1]^2 with (0.5, 0.5) at the
    // centre. Distance from centre gives a circular mask + soft falloff -- the basis for a real
    // point-sprite look without uploading a texture.
    let from_center = gl_PointCoord - float2(0.5, 0.5)
    let d = sqrt(dot(from_center, from_center)) * 2.0     // 0 at centre, 1 at the edge of the inscribed circle
    if (d > 1.0) {
        discard()                                          // outside the disc: punch a hole through the square
    }
    // soft glow: bright core, falloff to zero alpha at the rim
    let core = 1.0 - clamp(d, 0.0, 1.0)
    let glow = core * core * core                          // cubic falloff reads as a hot core + halo
    // colour by speed: cool teal for slow particles, hot magenta for fast
    let cool = float3(0.20, 0.70, 1.00)
    let hot  = float3(1.00, 0.30, 0.55)
    let s = clamp(f_speed * 0.4, 0.0, 1.0)
    let s3 = float3(s, s, s)
    let base = lerp(cool, hot, s3)
    // Additive blending on the pipeline means premultiplied colour with alpha=glow gives a pure
    // additive glow with a per-pixel falloff -- the canonical "particle" look.
    let intensity = glow * f_life
    frag_color = float4(base * intensity, intensity)
}

The render (headless)

The host builds two pipelines against one shared VkBuffer. The compute pipeline binds the buffer as an SSBO (STORAGE_BUFFER descriptor); the graphics pipeline binds the same buffer as a per-vertex stream with attribute offsets matching the std430-laid-out Particle struct. The compute pipeline is built with the boost helper create_compute_pipeline; the graphics pipeline goes through a small local build_points_pipeline because v3d’s TRIANGLE_LIST topology isn’t what we want here.

def public update_particles_uniforms(res : ParticlesResources; device : Device; time, camera_t : float) {
    let _t = time
    let camera_angle = camera_t * 2.0f * PI
    let cam_r = 3.0f
    let cam_x = cam_r * cos(camera_angle)
    let cam_z = cam_r * sin(camera_angle)
    let cam_y = sin(camera_angle * 2.0f) * 0.5f
    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(PART_W) / float(PART_H), 0.1f, 30.0f)
    particles_vs_bind_uniform(device, res.ubo.memory)
}

//! Record the compute dispatch + the compute->graphics barrier + the points draw into
//! the given command buffer.
def public record_particles_render_pass(res : ParticlesResources; cmd : CommandBuffer; time, dt : float) {
    let raw_cmd = boost_value_to_vk(cmd)

    // 1) Compute pass: dispatch the integrator over all N_PARTICLES particles.
    cmd_bind_pipeline(cmd, res.cpipeline, VkPipelineBindPoint.COMPUTE)
    let csets <- [vk_value_to_boost(res.cdesc_set)]
    var no_cdyn : array<uint>
    cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.COMPUTE, res.cpipe_layout, 0u, csets, no_cdyn)
    var cpc = ComputePush(time = time, dt = dt)
    var cstage : VkShaderStageFlags
    cstage.compute = true
    cmd_push_constants(cmd, res.cpipe_layout, cstage, 0u, cpc)
    cmd_dispatch(cmd, uint(N_PARTICLES / 64), 1u, 1u)

    // 2) Compute -> graphics barrier: the integrator's last write (SHADER_WRITE in COMPUTE_SHADER) must
    //    be visible to the vertex-input fetch (VERTEX_ATTRIBUTE_READ in VERTEX_INPUT) before graphics
    //    reads particles[i].pos. Without it the graphics pipeline races compute and reads stale values.
    var bmb : VkBufferMemoryBarrier
    bmb.srcAccessMask.shader_write = true
    bmb.dstAccessMask.vertex_attribute_read = true
    bmb.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
    bmb.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
    bmb.buffer = boost_value_to_vk(res.particle_buf.buffer)
    bmb.offset = 0ul
    bmb.size = uint64(N_PARTICLES * int(PARTICLE_STRIDE))
    var src_stage : VkPipelineStageFlags
    src_stage.compute_shader = true
    var dst_stage : VkPipelineStageFlags
    dst_stage.vertex_input = true
    var bmbs : array<VkBufferMemoryBarrier>
    bmbs |> push(bmb)
    let no_dep : VkDependencyFlags
    unsafe {
        vkCmdPipelineBarrier(raw_cmd, src_stage, dst_stage,
            no_dep,
            0u, null,
            1u, addr(bmbs[0]),
            0u, null)
    }
    delete bmbs

    // 3) Graphics pass: bind the SAME particle buffer (the bytes the compute just wrote) at
    //    binding 0 as a per-vertex stream, draw N_PARTICLES points (POINT_LIST topology).
    var clears <- [clear_color(0.01f, 0.0f, 0.03f, 1.0f), clear_depth(1.0f)]
    record_render_pass(cmd, res.render_pass, res.framebuffer, full_area(PART_W, PART_H), clears) {
        cmd_bind_pipeline(cmd, res.gpipeline)
        let gsets <- [vk_value_to_boost(res.gdesc_set)]
        var no_gdyn : array<uint>
        cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.gpipe_layout, 0u, gsets, no_gdyn)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.particle_buf.buffer))
        cmd_draw(cmd, uint(N_PARTICLES))
    }
    delete clears
}

record_particles_render_pass is the per-frame work: a compute dispatch, a vkCmdPipelineBarrier against the shared buffer (the handoff), and the POINT_LIST draw inside the render pass.

def public record_particles_render_pass(res : ParticlesResources; cmd : CommandBuffer; time, dt : float) {
    let raw_cmd = boost_value_to_vk(cmd)

    // 1) Compute pass: dispatch the integrator over all N_PARTICLES particles.
    cmd_bind_pipeline(cmd, res.cpipeline, VkPipelineBindPoint.COMPUTE)
    let csets <- [vk_value_to_boost(res.cdesc_set)]
    var no_cdyn : array<uint>
    cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.COMPUTE, res.cpipe_layout, 0u, csets, no_cdyn)
    var cpc = ComputePush(time = time, dt = dt)
    var cstage : VkShaderStageFlags
    cstage.compute = true
    cmd_push_constants(cmd, res.cpipe_layout, cstage, 0u, cpc)
    cmd_dispatch(cmd, uint(N_PARTICLES / 64), 1u, 1u)

    // 2) Compute -> graphics barrier: the integrator's last write (SHADER_WRITE in COMPUTE_SHADER) must
    //    be visible to the vertex-input fetch (VERTEX_ATTRIBUTE_READ in VERTEX_INPUT) before graphics
    //    reads particles[i].pos. Without it the graphics pipeline races compute and reads stale values.
    var bmb : VkBufferMemoryBarrier
    bmb.srcAccessMask.shader_write = true
    bmb.dstAccessMask.vertex_attribute_read = true
    bmb.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
    bmb.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
    bmb.buffer = boost_value_to_vk(res.particle_buf.buffer)
    bmb.offset = 0ul
    bmb.size = uint64(N_PARTICLES * int(PARTICLE_STRIDE))
    var src_stage : VkPipelineStageFlags
    src_stage.compute_shader = true
    var dst_stage : VkPipelineStageFlags
    dst_stage.vertex_input = true
    var bmbs : array<VkBufferMemoryBarrier>
    bmbs |> push(bmb)
    let no_dep : VkDependencyFlags
    unsafe {
        vkCmdPipelineBarrier(raw_cmd, src_stage, dst_stage,
            no_dep,
            0u, null,
            1u, addr(bmbs[0]),
            0u, null)
    }
    delete bmbs

    // 3) Graphics pass: bind the SAME particle buffer (the bytes the compute just wrote) at
    //    binding 0 as a per-vertex stream, draw N_PARTICLES points (POINT_LIST topology).
    var clears <- [clear_color(0.01f, 0.0f, 0.03f, 1.0f), clear_depth(1.0f)]
    record_render_pass(cmd, res.render_pass, res.framebuffer, full_area(PART_W, PART_H), clears) {
        cmd_bind_pipeline(cmd, res.gpipeline)
        let gsets <- [vk_value_to_boost(res.gdesc_set)]
        var no_gdyn : array<uint>
        cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.gpipe_layout, 0u, gsets, no_gdyn)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.particle_buf.buffer))
        cmd_draw(cmd, uint(N_PARTICLES))
    }
    delete clears
}

Self-verifying

The test is the CI regression gate (lavapipe in CI, real GPU locally). After one compute step it walks the whole frame counting non-background pixels, asserting that most of the 1024 particles are visible, and that the speed palette spans both the cool-teal and hot-magenta ends.

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

    // Walk the full frame and count non-bg pixels; with PARTICLE_PX=10 each splat covers ~78
    // pixels (pi * 5^2), so 1024 particles minus overlap/edge-clip land around ~25k pixels.
    var non_bg = 0
    var saw_teal = false
    var saw_magenta = false
    var max_b = 0
    var max_r = 0
    for (y in range(PART_H)) {
        for (x in range(PART_W)) {
            let p = px(pixels, x, y)
            if (bg(p)) {
                continue
            }
            non_bg++
            if (p.z > max_b) {
                max_b = p.z
            }
            if (p.x > max_r) {
                max_r = p.x
            }
            // sRGB target: gamma-encoding compresses the blue/red gap relative to linear. Empirically
            // the teal palette (cool = float3(0.20, 0.70, 1.00)) yields blue > red by 10+ at brightness
            // 60+, so requiring a gap >= 10 confirms a genuinely teal-hued particle was rasterised.
            if (p.z > 60 && p.z > p.x + 10) {
                saw_teal = true
            }
            if (p.x > 130 && p.x > p.z + 20) {
                saw_magenta = true
            }
        }
    }
    t |> success(non_bg > 10000, "found > 10000 non-background pixels (most of {N_PARTICLES} particles rasterised, got {non_bg})")
    t |> success(max_b > 130,  "saw a strong-blue particle pixel (cool end of the palette reached, max_b={max_b})")
    t |> success(max_r > 130,  "saw a strong-red particle pixel (hot end of the palette reached, max_r={max_r})")
    t |> success(saw_teal,     "found a teal-dominant particle (blue clearly above red, max_b={max_b})")

    delete pixels
}

See it live

window/show_particles.das opens a GLFW window with a Vulkan swapchain and runs the integrator + draw every frame with dt derived from wall-clock. It owns its own instance (with surface extensions) + device (with VK_KHR_swapchain), then calls build_particles_resources to share the offscreen render pass + both pipelines + the shared particle buffer with the headless oracle. Each frame it runs update_particles_uniforms + record_particles_render_pass into the present command buffer, then blits the colour attachment onto the swapchain image.

require glfw/glfw_boost
require vulkan
require vulkan/vulkan_boost
require vulkan/vulkan_window
require ../particles_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(PART_W, PART_H, "dasVulkan tutorial 07 - compute-driven particles (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 07 (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 features : VkPhysicalDeviceFeatures
    features.largePoints = VK_TRUE                   // gl_PointSize > 1 (POINT_LIST splat width)
    var inscope device <- create_device(phys, gfx, ["VK_KHR_swapchain"], features)
    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_particles_resources(device, phys, queue, pool)
    var inscope swap <- create_swapchain(device, phys, surface, PART_W, PART_H)
    var inscope sync <- create_frame_sync(device)

    var last_t = float(glfwGetTime())
    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())
        let dt = min(0.05f, t - last_t)            // cap dt so a long stall doesn't fling particles
        last_t = t
        // 30-second camera orbit -- one full circle per 30s of wall clock.
        let raw_cam = t * 0.0333f
        let camera_t = raw_cam - floor(raw_cam)
        update_particles_uniforms(res, device, t, camera_t)

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

            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 = PART_W
            region.srcOffsets[1].y = PART_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/07_particles

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

# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/07_particles/recording/record_particles.das

Next

08 - Shadow Mapping: Two Passes, One Depth Image runs two render passes per frame: a depth-only pass from the light’s POV writes a shadow map, then the main pass reads that same image as a sampler2DShadow. Same “one image, two roles” discipline as this tutorial’s compute SSBO + vertex stream, lifted to a depth texture across passes.