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 throughparticles[i].fieldchained access chains; dasSpirv lowers them to a pair ofOpAccessChainoperations (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 barrier –
vkCmdPipelineBarrierwithsrcStageMask=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_PXpixels wide. The vertex shader writesgl_PointSize(BuiltInPointSizeOutput) and the host enables thelargePointsphysical-device feature so sizes > 1 take effect; the graphics pipeline is built inline (v3d hard-codesTRIANGLE_LIST) and usesVkPrimitiveTopology.POINT_LISTwith 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(BuiltInPointCoordInput), the UV inside the rasterised point primitive ([0, 1]^2with(0.5, 0.5)at the centre). Distance from centre drives a circulardiscard()mask + a cubic falloff for the core/halo glow – no sprite texture upload needed.Additive blending + depth-test-without-write – the pipeline runs
srcColor + dstColorwith both factors set toONEso 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 withalpha = 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.