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_normalflow in through@in @locationstage inputs instead of being fabricated fromgl_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_SFLOATattachment,LESS_OR_EQUAL);normalize/dot/powfrom 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.