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.