15 - Hardware Ray Tracing

Everything so far rasterized. This tutorial fires real rays: the scene – a box on a ground plane – is built into acceleration structures, a VK_KHR_ray_tracing_pipeline traces one camera ray per pixel into it, and each hit casts a second shadow ray toward the light. No render pass, no vertex input, no rasterizer: the raygen shader writes the frame directly into a storage image.

The headline rails:

  • Three new shader stages, all daslang. [vulkan_raygen_shader] fires the pinhole-camera rays and imageStores the result; [vulkan_miss_shader] paints the sky; [vulkan_closest_hit_shader] flat-shades the hit. dasSpirv lowers all three to SPIR-V 1.4 with the RayTracingKHR capability – @ray_payload / @incoming_ray_payload globals carry data between them, and traceRayEXT(...) is the daslang spelling of OpTraceRayKHR.

  • BLAS + TLAS in two calls. build_blas uploads the triangle soup and runs the GPU build; build_tlas places it with make_accel_instance (the hand-packed stand-in for VkAccelerationStructureInstanceKHR’s C bitfields). Both block until built, so the resulting AccelStructures are immediately traceable.

  • The shader binding table, assembled for you. create_ray_tracing_pipeline lays the groups out as [raygen, miss..., hit] and build_shader_binding_table fetches the opaque group handles, applies the device’s alignment rules, and returns the four strided regions cmd_trace_rays consumes.

  • Shadow without recursion into shading. The closest-hit’s shadow ray uses terminate-on-first-hit + skip-closest-hit flags and miss_index = 1: no hit shader ever runs for it – only the second miss shader, which flips the payload to “lit” when the ray reaches the light unobstructed.

  • The descriptor side is reflection-driven. The TLAS binding reflects as VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR and the layouts come straight from build_descriptor_set_layouts / build_pipeline_layout – the same reflection path every raster tutorial uses, now RT-stage-aware.

The clip above is the headless recording: 30 seconds, 30 fps, one full camera orbit – every frame is a real vkCmdTraceRaysKHR, and the traced shadow swings around the box as the camera circles. The [test] pixel-oracle is the strongest in the series: the same daslang scene tables and shading functions also run in the interpreter as a CPU reference tracer, and the GPU frame must match it pixel-for-pixel (up to a small silhouette budget where hardware traversal and the CPU intersector round the boundary differently). One source. Two execution tiers. Identical pixels.

Note

Hardware ray tracing requires VK_KHR_ray_tracing_pipeline + VK_KHR_acceleration_structure with the bufferDeviceAddress feature (rt_supported probes all of it). The tutorial soft-skips on devices without RT support (lavapipe in CI, so the test passes as skipped) – the recording here is from an RT-capable GPU.

The shaders

Four entry points: raygen (camera + imageStore), the sky miss, the shadow miss (the “lit” flip), and the closest-hit (flat normal by gl_PrimitiveID, lambert, one shadow ray). The scene tables and shading functions they call are plain daslang functions – the CPU reference tracer in the test calls the very same ones.

module raytracing_tut_shaders public

require vulkan/vulkan_boost public  // Device + DeviceMemory used by the generated bind helpers
require vulkan/spirv_vulkan_shader public
require spirv/spirv_builtins public
require math public

// ===== the scene: a box on a ground plane =====

//! 12 vertices: ground quad [-30,30]^2 at y=0, then the 1.5-unit box resting on it.
def rt_scene_vertices : array<float3> {
    return <- [
        float3(-30.0, 0.0, -30.0), float3(30.0, 0.0, -30.0),
        float3(30.0, 0.0, 30.0), float3(-30.0, 0.0, 30.0),
        float3(-0.75, 0.0, -0.75), float3(0.75, 0.0, -0.75),
        float3(0.75, 0.0, 0.75), float3(-0.75, 0.0, 0.75),
        float3(-0.75, 1.5, -0.75), float3(0.75, 1.5, -0.75),
        float3(0.75, 1.5, 0.75), float3(-0.75, 1.5, 0.75)
    ]
}

//! 14 triangles: prims 0-1 ground, then box faces front/back/left/right/top/bottom (2 each) —
//! the order rt_normal() keys off.
def rt_scene_indices : array<uint> {
    return <- [
        0u, 1u, 2u, 0u, 2u, 3u,        // ground
        4u, 5u, 9u, 4u, 9u, 8u,        // front  (-z)
        7u, 6u, 10u, 7u, 10u, 11u,     // back   (+z)
        4u, 7u, 11u, 4u, 11u, 8u,      // left   (-x)
        5u, 6u, 10u, 5u, 10u, 9u,      // right  (+x)
        8u, 9u, 10u, 8u, 10u, 11u,     // top    (+y)
        4u, 5u, 6u, 4u, 6u, 7u         // bottom (-y)
    ]
}

//! flat per-face normal by primitive index (the closest-hit shader's whole "vertex fetch")
def rt_normal(prim : int) : float3 {
    let normals = fixed_array(
        float3(0.0, 1.0, 0.0), float3(0.0, 1.0, 0.0),
        float3(0.0, 0.0, -1.0), float3(0.0, 0.0, -1.0),
        float3(0.0, 0.0, 1.0), float3(0.0, 0.0, 1.0),
        float3(-1.0, 0.0, 0.0), float3(-1.0, 0.0, 0.0),
        float3(1.0, 0.0, 0.0), float3(1.0, 0.0, 0.0),
        float3(0.0, 1.0, 0.0), float3(0.0, 1.0, 0.0),
        float3(0.0, -1.0, 0.0), float3(0.0, -1.0, 0.0))
    return normals[prim]
}

def rt_ground_albedo : float3 => float3(0.42, 0.52, 0.42)
def rt_box_albedo : float3 => float3(0.85, 0.35, 0.18)

//! unit vector TOWARD the light: -normalize(float3(0.45, -1.0, 0.35)), pre-folded so the shader
//! reads plain literals
def rt_to_light : float3 => float3(-0.390936, 0.868747, -0.304062)

// ===== camera (orbits the box at CAM_HEIGHT, always looking at CAM_TARGET) =====

let CAM_DIST = 4.5
let CAM_HEIGHT = 2.2
let CAM_TAN = 0.5773503   // tan(30 deg): 60-degree vertical FOV

def rt_camera_origin(angle : float) : float3 => float3(sin(angle) * CAM_DIST, CAM_HEIGHT, -cos(angle) * CAM_DIST)

def rt_camera_dir(origin : float3; px, py : float; w, h : float) : float3 {
    let fwd = normalize(float3(0.0, 0.6, 0.0) - origin)
    let right = normalize(cross(fwd, float3(0.0, 1.0, 0.0)))
    let up = cross(right, fwd)
    let u = (px + 0.5) / w * 2.0 - 1.0
    let v = 1.0 - (py + 0.5) / h * 2.0   // image row 0 is the top -> +v is up
    return normalize(fwd + right * (u * CAM_TAN) + up * (v * CAM_TAN))
}

// ===== shading (shared GPU / CPU) =====

def sky_color(dir : float3) : float3 {
    let t = clamp(dir.y, 0.0, 1.0)
    return float3(0.75, 0.85, 0.95) + (float3(0.25, 0.45, 0.8) - float3(0.75, 0.85, 0.95)) * t
}

def shade_lambert(n, albedo : float3; lit : float) : float3 {
    let ndl = max(dot(n, rt_to_light()), 0.0)
    return albedo * (0.18 + 0.82 * ndl * lit)
}

// ===== the shaders =====

struct RtPush {
    angle : float
}
var @push_constant pc : RtPush

var @set = 0 @binding = 0 rt_tlas : accelerationStructureEXT
var @set = 0 @binding = 1 rt_img : image2D
var @ray_payload prd : float3

[vulkan_raygen_shader(name="raytracing_rgen_spv")]
def raytracing_rgen {
    let gid = gl_LaunchIDEXT
    let dim = gl_LaunchSizeEXT
    let origin = rt_camera_origin(pc.angle)
    let dir = rt_camera_dir(origin, float(gid.x), float(gid.y), float(dim.x), float(dim.y))
    prd = float3(0.0, 0.0, 0.0)
    traceRayEXT(rt_tlas, 0x01u, 0xFFu, 0u, 0u, 0u, origin, 0.001, dir, 100.0, prd)
    imageStore(rt_img, int2(int(gid.x), int(gid.y)), float4(prd, 1.0))
}

var @incoming_ray_payload miss_prd : float3

[vulkan_miss_shader(name="raytracing_miss_spv")]
def raytracing_miss {
    miss_prd = sky_color(gl_WorldRayDirectionEXT)
}

var @incoming_ray_payload shadow_prd : float3

[vulkan_miss_shader(name="raytracing_shadow_miss_spv")]
def raytracing_shadow_miss {
    shadow_prd = float3(1.0, 1.0, 1.0)   // the shadow ray reached the light: lit
}

var @incoming_ray_payload hit_prd : float3
var @ray_payload @location = 1 shadow_ray : float3

[vulkan_closest_hit_shader(name="raytracing_chit_spv")]
def raytracing_chit {
    let n = rt_normal(gl_PrimitiveID)
    var albedo = rt_box_albedo()
    if (gl_PrimitiveID < 2) {
        albedo = rt_ground_albedo()
    }
    let p = gl_WorldRayOriginEXT + gl_WorldRayDirectionEXT * gl_HitTEXT
    // shadow ray: opaque (0x1) + terminate-on-first-hit (0x4) + skip-closest-hit (0x8). No hit
    // shader ever runs -- only the shadow miss (miss_index = 1) flips the payload to lit.
    shadow_ray = float3(0.0, 0.0, 0.0)
    traceRayEXT(rt_tlas, 0x0Du, 0xFFu, 0u, 0u, 1u,
        p + n * 0.001, 0.001, rt_to_light(), 100.0, shadow_ray)
    hit_prd = shade_lambert(n, albedo, shadow_ray.x)
}

The host side

build_rt_view assembles the resident RT stack – BLAS, TLAS, reflection-built layouts, the pipeline, its shader binding table, and the storage image – and record_trace re-fires the whole frame from one push constant (the camera orbit angle).

struct RtView {
    blas : AccelStructure
    tlas : AccelStructure
    set_layouts : array<DescriptorSetLayout>
    pipe_layout : PipelineLayout
    desc_pool : DescriptorPool
    dsets : array<DescriptorSet>
    pipeline : Pipeline
    sbt : ShaderBindingTable
    image : Image
    memory : DeviceMemory
    view : ImageView
    width : int
    height : int
}

def finalize(var rv : RtView) {
    delete rv.sbt
    delete rv.pipeline
    delete rv.desc_pool   // frees the sets allocated from it
    delete rv.dsets
    delete rv.pipe_layout
    delete rv.set_layouts
    delete rv.view
    delete rv.image
    delete rv.memory
    delete rv.tlas
    delete rv.blas
}

//! Build the resident RT stack. The device must come from create_device_ray_tracing; pool/queue
//! are used (and waited idle) for the BLAS/TLAS builds.
def build_rt_view(device : Device; phys : VkPhysicalDevice; pool : CommandPool; queue : VkQueue;   // nolint:STYLE038 - flat one-call-per-item Vulkan setup run
                  w, h : int) : RtView {
    let dev = boost_value_to_vk(device)

    // acceleration structures over the scene tables
    var verts <- rt_scene_vertices()
    var idx <- rt_scene_indices()
    var inscope blas <- build_blas(device, phys, pool, queue, verts, idx)
    delete verts
    delete idx
    var insts <- [make_accel_instance(blas.address)]
    var inscope tlas <- build_tlas(device, phys, pool, queue, insts)
    delete insts

    // layouts straight from shader reflection: set 0 = { binding 0 TLAS, binding 1 storage image },
    // plus the raygen's `angle` push-constant range
    var reflections <- [decode_reflection(raytracing_rgen_spv_reflect),
        decode_reflection(raytracing_miss_spv_reflect),
        decode_reflection(raytracing_shadow_miss_spv_reflect),
        decode_reflection(raytracing_chit_spv_reflect)]
    var inscope set_layouts <- build_descriptor_set_layouts(device, reflections)
    var inscope pipe_layout <- build_pipeline_layout(device, set_layouts, reflections)
    delete reflections

    var dpci = DescriptorPoolCreateInfo(maxSets = 1u,
        pPoolSizes <- [
            DescriptorPoolSize(type_ = VkDescriptorType.ACCELERATION_STRUCTURE_KHR, descriptorCount = 1u),
            DescriptorPoolSize(type_ = VkDescriptorType.STORAGE_IMAGE, descriptorCount = 1u)
        ])
    var inscope desc_pool <- create_descriptor_pool(device, dpci)
    var dsai : DescriptorSetAllocateInfo
    dsai.descriptorPool = weak_copy(desc_pool)
    dsai.pSetLayouts |> push(weak_copy(set_layouts[0]))
    var inscope dsets <- allocate_descriptor_sets(device, dsai)

    // the storage image the raygen writes (TRANSFER_SRC for both the readback and the blit path)
    let fmt = VkFormat.R8G8B8A8_UNORM
    var ici = ImageCreateInfo(imageType = VkImageType._2D, format = fmt, mipLevels = 1u,
        arrayLayers = 1u, tiling = VkImageTiling.OPTIMAL, initialLayout = VkImageLayout.UNDEFINED)
    ici.extent.width = uint(w)
    ici.extent.height = uint(h)
    ici.extent.depth = 1u
    ici.samples._1 = true
    ici.usage.storage = true
    ici.usage.transfer_src = true
    var inscope image <- create_image(device, ici)
    var ireq : VkMemoryRequirements
    vkGetImageMemoryRequirements(dev, boost_value_to_vk(image), ireq)
    var iwant : VkMemoryPropertyFlags
    iwant.device_local = true
    let imai = MemoryAllocateInfo(allocationSize = ireq.size,
        memoryTypeIndex = find_memory_type(phys, ireq.memoryTypeBits, iwant))
    var inscope imem <- allocate_memory(device, imai)
    vk_check(vkBindImageMemory(dev, boost_value_to_vk(image), boost_value_to_vk(imem), 0ul), null)
    var vci = ImageViewCreateInfo(image = weak_copy(image), viewType = VkImageViewType._2D, format = fmt)
    vci.subresourceRange.aspectMask.color = true
    vci.subresourceRange.levelCount = 1u
    vci.subresourceRange.layerCount = 1u
    var inscope view <- create_image_view(device, vci)

    // descriptor writes: the TLAS through the pNext rail, the image through the boost path
    write_descriptor_acceleration_structure(device, dsets[0], 0u, tlas)
    var write = WriteDescriptorSet(dstSet = weak_copy(dsets[0]), dstBinding = 1u,
        descriptorType = VkDescriptorType.STORAGE_IMAGE, descriptorCount = 1u)
    write.pImageInfo |> push(DescriptorImageInfo(imageView = weak_copy(view), imageLayout = VkImageLayout.GENERAL))
    var writes : array<WriteDescriptorSet>
    writes |> emplace(write)
    let no_copies : array<CopyDescriptorSet>
    update_descriptor_sets(device, writes, no_copies)

    // pipeline (raygen + [sky miss, shadow miss] + closest-hit) + its SBT; the shader modules may
    // be destroyed once the pipeline exists, so they stay scope-local
    var inscope rgen <- create_shader_module(device, raytracing_rgen_spv)
    var inscope miss_sky <- create_shader_module(device, raytracing_miss_spv)
    var inscope miss_shadow <- create_shader_module(device, raytracing_shadow_miss_spv)
    var inscope chit <- create_shader_module(device, raytracing_chit_spv)
    var miss_list <- [weak_copy(miss_sky), weak_copy(miss_shadow)]
    var inscope pipeline <- create_ray_tracing_pipeline(device, pipe_layout, rgen, miss_list, chit, 2u)
    delete miss_list
    var inscope sbt <- build_shader_binding_table(device, phys, pipeline, 2u, 1u)

    return <- RtView(blas <- blas, tlas <- tlas, set_layouts <- set_layouts,
        pipe_layout <- pipe_layout, desc_pool <- desc_pool, dsets <- dsets,
        pipeline <- pipeline, sbt <- sbt, image <- image, memory <- imem, view <- view,
        width = w, height = h)
}

//! Record one full trace of the scene into `cmd` for the given camera orbit angle. The caller
//! owns the image layout: it must be GENERAL before this call, and the barriers around the
//! raygen's writes are the caller's (see render_raytracing / the windowed viewer).
def record_trace(cmd : CommandBuffer; rv : RtView; angle : float) {
    cmd_bind_pipeline(cmd, rv.pipeline, VkPipelineBindPoint.RAY_TRACING_KHR)
    var sets : array<DescriptorSet>
    sets |> push(weak_copy(rv.dsets[0]))
    var no_dyn : array<uint>
    cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.RAY_TRACING_KHR, rv.pipe_layout, 0u, sets, no_dyn)
    pc.angle = angle
    raytracing_rgen_push_constants(cmd, rv.pipe_layout)
    cmd_trace_rays(cmd, rv.sbt, uint(rv.width), uint(rv.height))
    delete sets
}

//! Everything a headless offscreen trace needs, owned together: its own instance/device plus the
//! resident RtView and a readback buffer. Built once (build_rt_offscreen), traced per frame
//! (render_rt_frame) -- the shape both the pixel-oracle test and the recording driver share.

Self-verifying

The pixel-oracle traces the same frame twice – hardware RT on the GPU, the Moller-Trumbore reference in the interpreter – and asserts they agree pixel-for-pixel within a small silhouette budget, after first proving the CPU frame contains all four regimes (sky, lit ground, the cast shadow, the box).

[test]
def test_raytracing(t : T?) {
    if (!rt_available()) {
        feint("VK_KHR_ray_tracing_pipeline not supported by this device; skipping (CI lavapipe has no RT)\n")
        return
    }
    var gpu <- render_raytracing(0.0, TEST_DIM, TEST_DIM)
    var cpu <- render_raytracing_cpu(0.0, TEST_DIM, TEST_DIM)
    t |> success(length(gpu) == TEST_DIM * TEST_DIM * 4, "GPU frame is {TEST_DIM}x{TEST_DIM} RGBA8")
    t |> success(length(cpu) == length(gpu), "CPU reference frame has the same size")

    // the CPU reference must contain all four regimes -- proves the scene really exercises the
    // sky miss, plain lambert, the shadow ray, and the box hit group
    var sky_px = 0
    var lit_ground_px = 0
    var shadow_ground_px = 0
    var box_px = 0
    let lit_ground = shade_lambert(float3(0.0, 1.0, 0.0), rt_ground_albedo(), 1.0)
    let dark_ground = shade_lambert(float3(0.0, 1.0, 0.0), rt_ground_albedo(), 0.0)
    for (py in range(TEST_DIM)) {
        for (px in range(TEST_DIM)) {
            let o = (py * TEST_DIM + px) * 4
            let r = int(cpu[o])
            let g = int(cpu[o + 1])
            let b = int(cpu[o + 2])
            if (b > r && b > g) {
                sky_px++   // both sky gradient stops are blue-dominant
            } elif (abs(r - int(255.0 * lit_ground.x)) <= 2 && abs(g - int(255.0 * lit_ground.y)) <= 2) {
                lit_ground_px++
            } elif (abs(r - int(255.0 * dark_ground.x)) <= 2 && abs(g - int(255.0 * dark_ground.y)) <= 2) {
                shadow_ground_px++
            } elif (r > g && r > b) {
                box_px++   // the box albedo is red-dominant at any lambert level
            }
        }
    }
    t |> success(sky_px > 100, "CPU reference shows sky ({sky_px} px)")
    t |> success(lit_ground_px > 100, "CPU reference shows lit ground ({lit_ground_px} px)")
    t |> success(shadow_ground_px > 100, "CPU reference shows the cast shadow ({shadow_ground_px} px)")
    t |> success(box_px > 100, "CPU reference shows the box ({box_px} px)")

    // GPU == CPU up to the silhouette budget
    var over_tol = 0
    var first_bad = int2(-1, -1)
    for (py in range(TEST_DIM)) {
        for (px in range(TEST_DIM)) {
            let o = (py * TEST_DIM + px) * 4
            var worst = 0
            for (c in range(3)) {
                let d = abs(int(gpu[o + c]) - int(cpu[o + c]))
                if (d > worst) {
                    worst = d
                }
            }
            if (worst > TOL) {
                over_tol++
                if (first_bad.x < 0) {
                    first_bad = int2(px, py)
                }
            }
        }
    }
    t |> success(over_tol <= EDGE_BUDGET,
        "GPU matches the CPU reference: {over_tol} of {TEST_DIM * TEST_DIM} pixels beyond +-{TOL} (budget {EDGE_BUDGET}, first at {first_bad})")
    delete gpu
    delete cpu
}

See it live

window/show_raytracing.das opens a GLFW window and traces the orbit live: build_rt_view once, then every frame is one push constant + one vkCmdTraceRaysKHR + a blit onto the swapchain image.

require glfw/glfw_boost
require vulkan
require vulkan/vulkan_boost
require vulkan/vulkan_window
require ../raytracing_tut.das
require daslib/defer

let RT_DIM = 768

[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(RT_DIM, RT_DIM, "dasVulkan tutorial 15 - hardware ray tracing", 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 15", 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)
    if (!rt_supported(phys)) {
        panic("this device does not support VK_KHR_ray_tracing_pipeline")
    }
    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_ray_tracing(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)

    // the resident RT stack: BLAS/TLAS + pipeline + SBT + the storage image the raygen writes
    var inscope rv <- build_rt_view(device, phys, pool, queue, RT_DIM, RT_DIM)

    var inscope swap <- create_swapchain(device, phys, surface, RT_DIM, RT_DIM)
    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 angle = float(glfwGetTime()) * 0.4
        let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
            let none : VkAccessFlags
            var shader_write : VkAccessFlags
            shader_write.shader_write = true
            var transfer_read : VkAccessFlags
            transfer_read.transfer_read = true
            var transfer_write : VkAccessFlags
            transfer_write.transfer_write = true
            var top : VkPipelineStageFlags
            top.top_of_pipe = true
            var rt_stage : VkPipelineStageFlags
            rt_stage.ray_tracing_shader_khr = true
            var xfer : VkPipelineStageFlags
            xfer.transfer = true
            var bottom : VkPipelineStageFlags
            bottom.bottom_of_pipe = true
            // trace this frame's orbit angle into the storage image (GENERAL)
            transition_image(cmd, rv.image, VkImageLayout.UNDEFINED, VkImageLayout.GENERAL,
                none, shader_write, top, rt_stage)
            record_trace(cmd, rv, angle)
            // make the raygen writes available to the blit (GENERAL is a legal blit source)
            transition_image(cmd, rv.image, VkImageLayout.GENERAL, VkImageLayout.GENERAL,
                shader_write, transfer_read, rt_stage, xfer)
            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 = rv.width
            region.srcOffsets[1].y = rv.height
            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(rv.image), VkImageLayout.GENERAL,
                    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 (skips cleanly without RT support)
daslang -load_module <dasVulkan> <daslang>/dastest/dastest.das -- \
    --test <dasVulkan>/tutorials/15_raytracing

# watch it live in a window (needs the glfw module + an RT-capable GPU)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/15_raytracing/window/show_raytracing.das

# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/15_raytracing/recording/record_raytracing.das