04 - The Synthwave Cube (graphics + depth)

01 - The Rotating Triangle drew with the graphics pipeline; 02 - The Mandelbrot Set (compute) and 03 - SDF raymarch (compute, no geometry) drew with the compute pipeline. This one is the first to need the whole graphics-with-depth surface: a textured 3D cube, lit, with a depth attachment so back faces are correctly hidden behind front faces. The headline rails:

  • a per-vertex buffer with position + uv + normal – a_pos / a_uv / a_normal flow in through @in @location stage inputs instead of being fabricated from gl_VertexIndex;

  • a uniform buffer with the view and projection matrices plus the camera position (and time);

  • a push constant carrying the per-frame model matrix;

  • a combined image sampler of a procedural synthwave horizon texture generated on the host at init;

  • depth test and write (D32_SFLOAT attachment, LESS_OR_EQUAL);

  • normalize / dot / pow from GLSL.std.450 for the rim + key lighting.

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 supersaw-pad bed. The camera orbits the cube on a lemniscate (small vertical figure-8 over the revolution) while the cube breathes via the push-constant scale; the texture’s sun-disk drifts via a UV scroll keyed off cam_time.w. See skills/recording.md. To watch it spin live in a resizable window on your own GPU, run the windowed viewer (see See it live below).

The shaders

A struct UBO with two float4x4 and one float4 member is the canonical std140 layout. The push constant is a single float4x4 model matrix at offset 0 – vertex-only, 64 bytes (well under the 128-byte push-constant minimum guarantee). The fragment shader reads cam.cam_time to fold the camera position into the view direction and the time into the UV scroll. The [vulkan_*_shader] annotations synthesise two host-side helpers from var @uniform cam : Camera and var @push_constant pc : ModelPC: cube_vs_bind_uniform(device, memory) writes each std140 field at its computed offset into a mapped UBO, and cube_vs_push_constants(cmd, layout) does the vkCmdPushConstants call. The host writes cam.* = ... and pc.model = ... and calls the helpers – one source of truth for the layout, no manual push_from / upload_bytes packing.

module cube_tut_shaders public

require vulkan/vulkan_boost public  // Device + DeviceMemory types used by the generated bind_uniform_<shader> functions
require vulkan/spirv_vulkan_shader public
require spirv/spirv_builtins public
require math

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

//! View+projection matrices and the camera world position (xyz) + time (w) packed in one vec4 so
//! the std140 layout has no scalar-after-vec3 packing surprises -- 64+64+16 = 144 bytes total.
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-frame model matrix (vertex-only push constant). 64 bytes -- well under the 128 byte
//! push-constant minimum guarantee.
struct ModelPC {
    model : float4x4
}
var @push_constant pc : ModelPC

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

var @in @location = 0 a_pos    : float3
var @in @location = 1 a_uv     : float2
var @in @location = 2 a_normal : float3

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

[vulkan_vertex_shader(name="cube_vert_spv")]
def cube_vs {
    let world = pc.model * float4(a_pos, 1.0)
    gl_Position = cam.proj * cam.view * world
    v_world_pos = world.xyz
    // assumes pc.model has uniform scale + rotation only (no shear / non-uniform scale), which is
    // true for the breathing rotating cube; in that case a vec4(normal, 0) multiply transforms the
    // normal correctly without an inverse-transpose.
    v_world_normal = (pc.model * float4(a_normal, 0.0)).xyz
    v_uv = a_uv
}

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

var @set = 0 @binding = 1 tex : sampler2D

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

[vulkan_fragment_shader(name="cube_frag_spv")]
def cube_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.0)            // silhouette glow
    let rim_color = float3(0.4, 0.6, 1.0)                    // cool cyan
    let scrolled = float2(f_uv.x, f_uv.y + cam.cam_time.w * 0.05)
    let albedo = texture(tex, scrolled).rgb
    let lit = albedo * key + rim * rim_color
    frag_color = float4(lit, 1.0)
}

The render (headless)

The per-frame work is split into two reusable halves so the windowed viewer can share the same render with the offscreen test. update_cube_uniforms writes the new view / projection / camera position into cam (the UBO) and the new model matrix into pc (the push constant) – host-only work, no command buffer touched. record_cube_render_pass records the pipeline bind, the descriptor set, the push-constants upload, and the indexed draw into a caller- supplied command buffer; the render pass’s finalLayout leaves the colour attachment in TRANSFER_SRC_OPTIMAL so a swapchain blit is one command away.

def public update_cube_uniforms(res : CubeResources; device : Device; time, camera_t : float) {
    let camera_angle = camera_t * 2.0f * PI
    let camera_r = 3.5f
    let cam_x = camera_r * cos(camera_angle)
    let cam_z = camera_r * sin(camera_angle)
    // Lemniscate at 2x frequency, but small amplitude -- the camera stays near the cube's height
    // (cube extends y in [-0.5, 0.5]) so the top/bottom faces, which are textured for a face-on
    // angle, only graze the view instead of dominating it.
    let cam_y = sin(camera_angle * 2.0f) * 0.4f
    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(CUBE_W) / float(CUBE_H), 0.1f, 10.0f)
    cam.cam_time = float4(cam_pos.x, cam_pos.y, cam_pos.z, time)
    cube_vs_bind_uniform(device, res.ubo.memory)

    let breathe = 1.0f + 0.05f * sin(time * 2.0f * PI / 5.0f)
    let spin = time * 0.5f
    pc.model = model_matrix(spin, breathe)
}

//! Record the cube's render pass + draw. The pass ends with the color attachment in TRANSFER_SRC_OPTIMAL
//! (create_render_pass_color_depth's default), so a windowed driver blits res.color.image straight into
//! the swap target. Caller must run update_cube_uniforms first to load the UBO + push-constant globals.
def public record_cube_render_pass(res : CubeResources; cmd : CommandBuffer) {
    var clears <- [clear_color(0.02f, 0.0f, 0.06f, 1.0f), clear_depth(1.0f)]
    record_render_pass(cmd, res.render_pass, res.framebuffer, full_area(CUBE_W, CUBE_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)
        cube_vs_push_constants(cmd, res.pipe_layout)
        let vbufs <- [weak_copy(res.vb.buffer)]
        var voffs : array<uint64>
        voffs |> push(0ul)
        cmd_bind_vertex_buffers(cmd, 0u, vbufs, voffs)
        cmd_bind_index_buffer(cmd, weak_copy(res.ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_draw_indexed(cmd, uint(N_INDICES), 1u, 0u, 0, 0u)
    }
    delete clears
}

render_cube_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_synthwave_cube is the one-shot wrapper (build_cube_context + render_cube_frame) the test calls; the recording driver builds the context once and calls render_cube_frame in a loop.

def public render_cube_frame(var ctx : CubeContext; time, camera_t : float) : array<uint8> {
    update_cube_uniforms(ctx.res, ctx.device, time, camera_t)
    var pixels : array<uint8>
    run_cmd_sync(ctx.device, ctx.pool, ctx.queue) $(cmd) {
        record_cube_render_pass(ctx.res, cmd)
        copy_image_to_buffer(cmd, ctx.res.color.image, ctx.res.readback, CUBE_W, CUBE_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, a real GPU locally). The cube has no analytic-symmetry shortcut the way Mandelbrot did; instead the oracle asserts a handful of structural properties at a fixed (time, camera_t): the frame corners are the dark clear colour, the central 200x200 box is dominantly the cube, and the lit region carries the synthwave palette (at least one magenta-dominant pixel from the sky band, at least one cyan-dominant pixel from the perspective grid).

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

    // four corners are background -- the cube does not fill the frame
    t |> success(bg(px(pixels, 8, 8)),                       "top-left corner is the clear color")
    t |> success(bg(px(pixels, CUBE_W - 8, 8)),              "top-right corner is the clear color")
    t |> success(bg(px(pixels, 8, CUBE_H - 8)),              "bottom-left corner is the clear color")
    t |> success(bg(px(pixels, CUBE_W - 8, CUBE_H - 8)),     "bottom-right corner is the clear color")

    // the cube projects onto the center: a 200x200 box sampled on a 20x20 grid is mostly NOT background
    var cube_hits = 0
    let cx = CUBE_W / 2
    let cy = CUBE_H / 2
    for (j in range(20)) {
        for (i in range(20)) {
            let dy = -100 + j * 10
            let dx = -100 + i * 10
            if (!bg(px(pixels, cx + dx, cy + dy))) {
                cube_hits++
            }
        }
    }
    t |> success(cube_hits >= 380, "central 200x200 box is dominantly cube, not background ({cube_hits}/400)")

    // the synthwave palette shows up: >= 1 magenta-dominant pixel (texture sky band) and >= 1 cyan-dominant
    // one (perspective grid) inside the cube's silhouette. Thresholds are calibrated for LINEAR sampling +
    // key/rim lighting -- the 1-texel cyan grid lines average ~50% into the dark indigo under LINEAR.
    var saw_magenta = false
    var saw_cyan = false
    for (j in range(50)) {
        for (i in range(50)) {
            let y = cy - 100 + j * 4
            let x = cx - 100 + i * 4
            let p = px(pixels, x, y)
            if (p.x > 140 && p.z > 120 && p.y < 120) {
                saw_magenta = true
            }
            if (p.z > 120 && p.y > 100 && p.x < 40) {
                saw_cyan = true
            }
        }
    }
    t |> success(saw_magenta, "found a magenta pixel inside the cube (synthwave sky band sampled)")
    t |> success(saw_cyan,    "found a cyan pixel inside the cube (perspective-grid sampled)")

    delete pixels
}

See it live

window/show_cube.das opens a GLFW window with a Vulkan swapchain and presents the spinning cube every frame. It owns its own instance (with surface extensions) and device (with VK_KHR_swapchain), then calls build_cube_resources to share the offscreen render pass, framebuffer, geometry, texture and graphics pipeline with the headless oracle. Each frame it runs update_cube_uniforms + record_cube_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); it is the run-and-watch companion to the headless oracle.

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

[export]
def main {   // nolint:STYLE038 - flat viewer lifecycle scaffold
    if (volkInitialize() != 0) {
        panic("no vulkan loader")
    }
    // Hand GLFW the loader volk just found (Homebrew prefix on macOS). Must precede glfwInit.
    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(CUBE_W, CUBE_H, "dasVulkan tutorial 04 - synthwave cube (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 04 (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_cube_resources(device, phys, queue, pool)
    var inscope swap <- create_swapchain(device, phys, surface, CUBE_W, CUBE_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())
        // 20-second lemniscate orbit -- one full pass per 20s of wall clock.
        let raw_cam = t * 0.05f
        let camera_t = raw_cam - floor(raw_cam)
        update_cube_uniforms(res, device, t, camera_t)

        let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
            // After this, res.color.image is in TRANSFER_SRC_OPTIMAL (render-pass finalLayout).
            record_cube_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 = CUBE_W
            region.srcOffsets[1].y = CUBE_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/04_cube

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

# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/04_cube/recording/record_cube.das

Next

05 - Instancing: A Thousand Cubes (one draw call) keeps the cube’s graphics+depth pipeline but draws a thousand of them in a single draw call — a second vertex binding with INPUT_RATE_INSTANCE feeds per-instance offset/color, and vkCmdDrawIndexed gains an instanceCount argument the vertex shader reads via gl_InstanceIndex.