01 - The Rotating Triangle

The canonical “hello triangle” – three vertices, per-vertex red/green/blue, interpolated across the face – but with every line of the shader written in daslang and lowered to SPIR-V at compile time by dasSpirv. No GLSL, no glslang, no committed .spv. A push-constant angle spins it, so the recording has a real per-frame GPU parameter to drive.

The clip above is the offscreen render – the same pixels the CI test checks – recorded to a video. To watch it spin live in a resizable window on your own GPU, run the windowed viewer (see See it live below).

See it live

window/show_triangle.das opens a GLFW window with a Vulkan swapchain and presents the spinning triangle every frame (FIFO present), driving the same SpinPush angle from wall-clock time. It reuses the exact tri_spin_*_spv blobs the offscreen render and the CI test use – the only additions are the window, surface and swapchain. 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 ../triangle_tut_shaders.das
require daslib/defer

let W = 800
let H = 600

[export]
def main {   // nolint:STYLE038 - flat viewer lifecycle scaffold
    if (volkInitialize() != 0) {
        panic("no vulkan loader")
    }
    // Hand GLFW the loader volk just found, so glfwVulkanSupported /
    // glfwGetRequiredInstanceExtensions work even where GLFW's own loader discovery
    // would miss it (e.g. the 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(W, H, "dasVulkan tutorial 01 - rotating triangle", null, null)
    if (window == null) {
        panic("can't create window")
    }
    defer() { glfwDestroyWindow(window) }

    // instance with the surface extensions GLFW requires for this platform
    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 01", 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 inscope swap <- create_swapchain(device, phys, surface, W, H)
    var inscope render_pass <- create_render_pass_single_color(device, swap.format, VkImageLayout.PRESENT_SRC_KHR)
    build_swapchain_framebuffers(device, swap, render_pass)

    // tri_spin_vert_spv / tri_spin_frag_spv: SPIR-V words emitted by dasSpirv from triangle_tut_shaders.das
    var inscope vert <- create_shader_module(device, tri_spin_vert_spv)
    var inscope frag <- create_shader_module(device, tri_spin_frag_spv)

    // pipeline layout with one vertex push-constant range (the angle: 4 bytes at offset 0)
    var plci : PipelineLayoutCreateInfo
    var pcr : PushConstantRange
    pcr.stageFlags.vertex = true
    pcr.offset = 0u
    pcr.size = 4u
    plci.pPushConstantRanges |> emplace(pcr)
    var inscope layout <- create_pipeline_layout(device, plci)
    delete plci.pPushConstantRanges   // owned input array; the layout copied the ranges, so free it now

    // dynamic viewport/scissor so the one pipeline survives every resize
    var inscope pipeline <- create_graphics_pipeline_simple(device, render_pass, layout, vert, frag, swap.width, swap.height, true)

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

    let clear = clear_color(0.1f, 0.1f, 0.15f, 1.0f)
    var frames = 0
    while (glfwWindowShouldClose(window) == 0) {
        glfwPollEvents()
        // skip rendering while minimized (zero-area framebuffer can't make a swapchain)
        var fbw = 0
        var fbh = 0
        glfwGetFramebufferSize(window, fbw, fbh)
        if (fbw == 0 || fbh == 0) {
            glfwWaitEvents()
            continue
        }
        // proactively match the window: recreate when the framebuffer size drifts
        if (fbw != swap.width || fbh != swap.height) {
            recreate_swapchain(swap, device, phys, surface, render_pass, fbw, fbh)
        }
        // one full turn every 2*pi seconds; pushed to the vertex shader each frame
        pc.angle = float(glfwGetTime())
        let ok = draw_frame(device, queue, swap, render_pass, pool, sync, clear, true) $(cmd) {
            cmd_bind_pipeline(cmd, pipeline)
            tri_spin_vert_push_constants(cmd, layout)
            cmd_draw(cmd, 3u)
        }
        if (!ok) {
            recreate_swapchain(swap, device, phys, surface, render_pass, fbw, fbh)
        }
        frames ++
    }
    vkDeviceWaitIdle(boost_value_to_vk(device))
    print("window closed after {frames} frames\n")
}

The shader

Both stages are plain daslang functions tagged [vulkan_vertex_shader] / [vulkan_fragment_shader]. The emitted tri_spin_vert_spv / tri_spin_frag_spv are array<uint> SPIR-V word blobs, fed straight to create_shader_module. The vertex stage reads the rotation angle from a @push_constant struct, builds a 2D rotation, and spins the hardcoded clip-space positions; the fragment stage just writes the interpolated varying. The vulkan_*_shader annotations also synthesise a per-shader <shader>_push_constants(cb, layout) helper that does the vkCmdPushConstants call for the host – see The render below.

options gen2
options _comment_hygiene = true
options indenting = 4

// Tutorial 01 - the rotating triangle. The classic gl_VertexIndex hello-triangle (shader-local
// clip-space positions + per-vertex RGB, interpolated across the face), spun by a push-constant
// angle so the tutorial's capture loop has a real per-frame GPU parameter to drive. Shaders are
// authored in daslang and lowered to SPIR-V at compile time by dasSpirv (require spirv/spirv_shader):
// the emitted tri_spin_vert_spv / tri_spin_frag_spv : array<uint> globals feed create_shader_module.

module triangle_tut_shaders public

require vulkan/vulkan_boost public  // CommandBuffer + PipelineLayout for the generated push_constants function
require vulkan/spirv_vulkan_shader public
require spirv/spirv_builtins public
require math

// the per-frame rotation angle (radians), pushed each draw. @push_constant blocks are struct-typed
// (the std430 Block the emitter decorates), so the lone float lives in a one-field struct.
struct SpinPush {
    angle : float
}
var @push_constant pc : SpinPush

var @out @location = 0 v_color : float3
[vulkan_vertex_shader(name="tri_spin_vert_spv")]
def tri_spin_vert {
    let positions = fixed_array(float2(0.0, -0.6), float2(0.6, 0.6), float2(-0.6, 0.6))
    let colors = fixed_array(float3(1.0, 0.0, 0.0), float3(0.0, 1.0, 0.0), float3(0.0, 0.0, 1.0))
    let p = positions[gl_VertexIndex]
    let c = cos(pc.angle)
    let s = sin(pc.angle)
    gl_Position = float4(p.x * c - p.y * s, p.x * s + p.y * c, 0.0, 1.0)
    v_color = colors[gl_VertexIndex]
}

var @in @location = 0 fi_color : float3
var @out @location = 0 frag_color : float4
[vulkan_fragment_shader(name="tri_spin_frag_spv")]
def tri_spin_frag {
    frag_color = float4(fi_color, 1.0)
}

The render

The offscreen render is the dasVulkan boost path – an offscreen color target, a single-color render pass, a graphics pipeline – with one addition over a static triangle: a vertex push-constant range carrying the angle, pushed each draw through the macro-generated tri_spin_vert_push_constants(cmd, layout). The host just writes pc.angle = angle to the shader’s @push_constant global; [vulkan_vertex_shader] synthesised the rest of the upload at compile time. render_spin_triangle(angle) returns the RGBA8 pixels, a pure parametric frame(angle) -> image.

def public render_spin_triangle(angle : float) : array<uint8> {
    if (volkInitialize() != 0) {
        panic("no Vulkan loader")
    }
    var pixels : array<uint8>
    var inscope instance <- create_instance("dasVulkan tutorial", make_api_version(1u, 3u, 0u))
    volkLoadInstance(boost_value_to_vk(instance))
    let phys = select_physical_device(instance)
    let gfx = select_graphics_queue_family(phys)
    var inscope device <- create_device(phys, gfx)
    volkLoadDevice(boost_value_to_vk(device))
    let queue = get_device_queue(device, gfx, 0u)
    let fmt = VkFormat.R8G8B8A8_SRGB   // sRGB: gamma-encode the linear output on write (recording readback + window blit match)

    var inscope target <- build_offscreen_target(device, phys, TRI_W, TRI_H, fmt)
    var inscope render_pass <- create_render_pass_single_color(device, fmt)
    var inscope framebuffer <- create_framebuffer(device, FramebufferCreateInfo(
        renderPass = weak_copy(render_pass),
        pAttachments <- [weak_copy(target.view)],
        width = uint(TRI_W),
        height = uint(TRI_H),
        layers = 1u))

    var inscope vert <- create_shader_module(device, tri_spin_vert_spv)
    var inscope frag <- create_shader_module(device, tri_spin_frag_spv)

    // pipeline layout with one vertex push-constant range (the angle: 4 bytes at offset 0)
    var plci : PipelineLayoutCreateInfo
    var pcr : PushConstantRange
    pcr.stageFlags.vertex = true
    pcr.offset = 0u
    pcr.size = 4u
    plci.pPushConstantRanges |> emplace(pcr)
    var inscope layout <- create_pipeline_layout(device, plci)
    delete plci.pPushConstantRanges   // owned input array; the layout copied the ranges, so free it now
    var inscope pipeline <- create_graphics_pipeline_simple(device, render_pass, layout, vert, frag, TRI_W, TRI_H)

    let buf_size = uint64(TRI_W * TRI_H * 4)
    var inscope readback <- create_host_buffer(device, phys, buf_size)
    var poolci : CommandPoolCreateInfo
    poolci.queueFamilyIndex = gfx
    var inscope pool <- create_command_pool(device, poolci)

    pc.angle = angle
    run_cmd_sync(device, pool, queue) $(cmd) {
        record_render_pass(cmd, render_pass, framebuffer, full_area(TRI_W, TRI_H), clear_color(0.1f, 0.1f, 0.15f, 1.0f)) {
            cmd_bind_pipeline(cmd, pipeline)
            tri_spin_vert_push_constants(cmd, layout)
            cmd_draw(cmd, 3u)
        }
        copy_image_to_buffer(cmd, target.image, readback, TRI_W, TRI_H)
    }
    map_memory_to_array(device, readback.memory, buf_size) $(m) {
        pixels := m
    }
    return <- pixels
}

//! Sample the RGB of pixel (x,y) from a TRI_W*TRI_H RGBA8 buffer.
def public px(pixels : array<uint8>; x, y : int) : int3 {
    let p = (y * TRI_W + x) * 4
    return int3(int(pixels[p]), int(pixels[p + 1]), int(pixels[p + 2]))
}

Self-verifying

The tutorial’s test is the CI regression gate – it runs on lavapipe in CI and a real GPU locally. At angle = 0 the geometry matches the classic static triangle, so the proven sample points hold (red top, green/blue bottom corners, an interpolated centroid). At angle = pi the red vertex must rotate to bottom-center and leave the top sample, proving the push-constant actually drives the vertex shader on the GPU.

[test]
def test_triangle_spin(t : T?) {
    // angle 0: identical geometry to the classic hello-triangle -- the proven sample points hold.
    var p0 <- render_spin_triangle(0.0f)
    let top0 = px(p0, 128, 80)
    let bl0 = px(p0, 75, 190)
    let br0 = px(p0, 180, 190)
    let mid0 = px(p0, 128, 150)
    t |> success(top0.x > top0.y + 40 && top0.x > top0.z + 40, "angle 0: top vertex region is red")
    t |> success(bl0.z > bl0.x + 40 && bl0.z > bl0.y + 40, "angle 0: bottom-left region is blue")
    t |> success(br0.y > br0.x + 40 && br0.y > br0.z + 40, "angle 0: bottom-right region is green")
    t |> success(mid0.x > 20 && mid0.y > 20 && mid0.z > 20, "angle 0: centroid shows the interpolated blend")
    delete p0

    // angle pi: a half turn. The red (top) vertex rotates to bottom-center and the top sample is no
    // longer red -- proving the push-constant angle drives the vertex shader on the GPU.
    var p1 <- render_spin_triangle(PI)
    let topP = px(p1, 128, 80)
    let botP = px(p1, 128, 190)
    t |> success(botP.x > botP.y + 40 && botP.x > botP.z + 40, "angle pi: red vertex rotated to bottom-center")
    t |> success(!(topP.x > topP.y + 40 && topP.x > topP.z + 40), "angle pi: top sample no longer red (rotation took effect)")
    delete p1
}

Running it

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

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

# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/01_triangle/recording/record_triangle.das

Next

02 - The Mandelbrot Set (compute) swaps the graphics pipeline for the compute pipeline: the shader writes pixel colors directly to a storage image via imageStore, no rasterizer involved. Same daslang→SPIR-V rails, different GPU stage.