8.15.16. 16 - Vulkan 1.4: Push Descriptors, Host Image Copy, Local Read

Vulkan 1.4 adds no new way to draw a frame – dynamic rendering and synchronization2, the shape every earlier tutorial uses, are 1.3. What 1.4 promotes to core are three things that change how a frame is put together, and this tutorial uses all three on one small scene: three textured cubes lit through a two-pass G-buffer.

The headline rails:

  • Host image copy. Each cube’s texture goes from host memory straight into the device image – no staging buffer, no transfer command, no queue. upload_image_from_host is a host-side layout transition out of UNDEFINED plus one vkCopyMemoryToImage; the image is ready to sample when the call returns. The device says which layouts it copies into (host_copy_layout asks for SHADER_READ_ONLY_OPTIMAL and takes GENERAL when that is refused), and the image is created with the host_transfer usage bit.

  • Push descriptors. Every descriptor the frame binds – the camera UBO, each cube’s texture, the two input attachments – is pushed into the command buffer with cmd_push_descriptor_set right before the draw that reads it. There is no descriptor pool and no set allocation anywhere; the set layouts come from the shaders’ reflection through build_push_descriptor_set_layouts, which is build_descriptor_set_layouts with the push_descriptor flag.

  • Dynamic rendering local read. The geometry pass writes albedo and the world normal into two attachments; the lighting pass reads both back at the same fragment through subpassInput / subpassLoad and composes the lit color into a third attachment – inside one record_rendering, with no render pass and no subpass. Two maps make it work, set once at pipeline creation and again in the command buffer before each pass’s draws: vkCmdSetRenderingAttachmentLocations says which fragment output location lands in which attachment, vkCmdSetRenderingInputAttachmentIndices says which attachment a subpassInput index reads. A by-region memory barrier between the passes orders the attachment writes before the input reads, and the G-buffer attachments stay in the RENDERING_LOCAL_READ layout, which is legal for both.

A 1.4 device comes from create_device_vulkan14, which turns the three features on behind the 1.3 pair; vulkan14_supported is the probe, and the oracle skips with a message where it fails. Lavapipe reports 1.4 from Mesa 25.0, which the nightly lane installs; a local GPU with a current driver reports it.

8.15.16.1. See it live

window/show_vulkan14.das opens a GLFW window on a 1.4 instance and device and blits the lit frame onto the swapchain every frame, the camera orbiting and the light swinging with wall-clock time. It needs a display, the glfw module and a Vulkan 1.4 driver, so it lives in the window/ subfolder the tutorial’s CI gate skips.

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

[export]
def main {
    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(V14_W, V14_H, "dasVulkan tutorial 16 - Vulkan 1.4: push descriptors, host image copy, local read", 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))) {
        inst_exts |> push(unsafe(glfw_exts[i]))
    }
    var inscope instance <- create_instance("dasVulkan tutorial 16 (window)", make_api_version(1u, 4u, 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 (!vulkan14_supported(phys)) {
        panic("this device or driver is below Vulkan 1.4, or lacks pushDescriptor / hostImageCopy / dynamicRenderingLocalRead")
    }
    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_vulkan14(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_vulkan14_resources(device, phys)
    var inscope swap <- create_swapchain(device, phys, surface, V14_W, V14_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) {
            recreate_swapchain(swap, device, phys, surface, fbw, fbh)
        }
        let t = float(glfwGetTime())
        update_vulkan14_uniforms(res, device, t)
        let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
            record_vulkan14_frame(res, cmd, t)
            blit_to_swapchain(cmd, res.color.image, VkImageLayout.TRANSFER_SRC_OPTIMAL, V14_W, V14_H, swap, target)
        }
        if (!ok) {
            recreate_swapchain(swap, device, phys, surface, fbw, fbh)
        }
    }
    vkDeviceWaitIdle(boost_value_to_vk(device))
}

8.15.16.2. The shaders

Four stages, all daslang. The geometry vertex stage transforms the cube and passes the world normal and UV on; its fragment stage writes the texture’s color to location 0 and the packed normal to location 1. The lighting vertex stage is the fullscreen triangle; its fragment stage declares the two attachments as subpassInput globals with @input_attachment_index and reads them with subpassLoad. One @push_constant struct serves both passes.

// ===== shared =====

//! View + projection, std140: two column-major mat4, 128 bytes.
struct Camera {
    view : float4x4
    proj : float4x4
}
var @uniform @set = 0 @binding = 0 cam : Camera

//! One push-constant block for both passes: the geometry pass stamps `model` per cube, the lighting
//! pass reads `light_dir` (xyz = unit direction TOWARD the light, w unused). 80 bytes.
struct ScenePC {
    model     : float4x4
    light_dir : float4
}
var @push_constant pc : ScenePC

// ===== geometry pass: cube -> albedo + normal =====

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_normal : float3
var @out @location = 1 v_uv     : float2

[vulkan_vertex_shader(name="gbuf_vert_spv")]
def gbuf_vs {
    gl_Position = cam.proj * cam.view * pc.model * float4(a_pos, 1.0)
    // pc.model is rotation + translation only, so the normal transforms with the same matrix
    v_normal = (pc.model * float4(a_normal, 0.0)).xyz
    v_uv = a_uv
}

var @set = 0 @binding = 1 tex : sampler2D   // this cube's texture, pushed before its draw

var @in @location = 0 f_normal : float3
var @in @location = 1 f_uv     : float2
var @out @location = 0 g_albedo : float4   // attachment 0 of the rendering
var @out @location = 1 g_normal : float4   // attachment 1: world normal packed into [0,1]

[vulkan_fragment_shader(name="gbuf_frag_spv")]
def gbuf_fs {
    g_albedo = float4(texture(tex, f_uv).rgb, 1.0)   // alpha 1 marks "a cube is here" for the lighting pass
    g_normal = float4(normalize(f_normal) * 0.5 + float3(0.5, 0.5, 0.5), 1.0)
}

// ===== lighting pass: albedo + normal -> lit color =====

//! Fullscreen triangle: (-1,-1), (3,-1), (-1,3) cover the whole viewport with three unindexed vertices.
[vulkan_vertex_shader(name="light_vert_spv")]
def light_vs {
    let xs = fixed_array(-1.0, 3.0, -1.0)
    let ys = fixed_array(-1.0, -1.0, 3.0)
    let i = gl_VertexIndex
    gl_Position = float4(xs[i], ys[i], 0.0, 1.0)
}

// The two G-buffer attachments, read at THIS fragment only. `@input_attachment_index` is the index
// the host maps attachment 0 and 1 to with vkCmdSetRenderingInputAttachmentIndices; the host binds
// them as INPUT_ATTACHMENT descriptors (pushed), in the RENDERING_LOCAL_READ layout.
var @set = 0 @binding = 0 @input_attachment_index = 0 in_albedo : subpassInput
var @set = 0 @binding = 1 @input_attachment_index = 1 in_normal : subpassInput

var @out @location = 0 out_color : float4   // location 0 is REMAPPED to attachment 2 by the host

[vulkan_fragment_shader(name="light_frag_spv")]
def light_fs {
    let albedo = subpassLoad(in_albedo)
    if (albedo.w < 0.5) {
        // nothing drawn here: a sky gradient, brighter toward the top (y = 0 is the top row in Vulkan)
        let t = gl_FragCoord.y / float(V14_H)
        out_color = float4(0.05 + 0.10 * (1.0 - t), 0.06 + 0.12 * (1.0 - t), 0.12 + 0.20 * (1.0 - t), 1.0)
        return
    }
    let n = normalize(subpassLoad(in_normal).xyz * 2.0 - float3(1.0, 1.0, 1.0))
    let l = normalize(pc.light_dir.xyz)
    let ambient = 0.15
    let diffuse = max(dot(n, l), 0.0) * (1.0 - ambient) + ambient
    out_color = float4(albedo.xyz * diffuse, 1.0)
}

8.15.16.3. The textures and the resources

create_host_copied_texture is the host-image-copy rail: an image with sampled + host_transfer usage, device-local memory, and one upload_image_from_host call that leaves it in the layout the device chose. build_vulkan14_resources then builds everything else – the three attachments and the depth buffer, the cube geometry, the camera UBO, the two pipelines on push-descriptor set layouts. Each pipeline is built by hand because it names three color attachments and carries its own location and input-index maps on the create info’s pNext chain (build_pass_pipeline).

//! One TEX_DIM x TEX_DIM RGBA8 texture whose usage carries ``host_transfer`` beside ``sampled``, filled
//! from `pixels` through a host image copy into `layout` (the device's choice from `host_copy_layout`).
//! No staging buffer, no command buffer, no queue: the image is ready to sample when this returns.
def create_host_copied_texture(device : Device; phys : VkPhysicalDevice; pixels : array<uint8>; layout : VkImageLayout) : OffscreenTarget {
    let usage = VkImageUsageFlags.sampled | VkImageUsageFlags.host_transfer     // host_transfer: the 1.4 usage bit a host copy needs
    var tex <- build_image(device, phys, TEX_DIM, TEX_DIM, VkFormat.R8G8B8A8_UNORM, VkSampleCountFlags._1, usage)
    upload_image_from_host(device, tex.image, pixels, TEX_DIM, TEX_DIM, layout)
    return <- tex
}

// ===== the two passes inside one rendering =====

//! Per pass, which attachment each fragment output location lands in and which attachment each ``subpassInput``
//! index reads: geometry writes attachments 0 and 1 and reads nothing, lighting writes 2 and reads 0 and 1.
//! Each pipeline is built with its pass's maps, and the command buffer sets the same maps before that pass's draws.
let GBUF_LOCATIONS <- [0u, 1u, VK_ATTACHMENT_UNUSED]
let GBUF_INPUTS <- [VK_ATTACHMENT_UNUSED, VK_ATTACHMENT_UNUSED, VK_ATTACHMENT_UNUSED]
let LIGHT_LOCATIONS <- [VK_ATTACHMENT_UNUSED, VK_ATTACHMENT_UNUSED, 0u]
let LIGHT_INPUTS <- [0u, 1u, VK_ATTACHMENT_UNUSED]

enum PassKind {
    geometry
    lighting
}

//! The cube vertex: pos (location 0), uv (1), normal (2).
def cube_vertex_attributes() : array<VkVertexInputAttributeDescription> {
    return <- [
        VkVertexInputAttributeDescription(location = 0u, format = VkFormat.R32G32B32_SFLOAT, offset = 0u),
        VkVertexInputAttributeDescription(location = 1u, format = VkFormat.R32G32_SFLOAT, offset = 12u),
        VkVertexInputAttributeDescription(location = 2u, format = VkFormat.R32G32B32_SFLOAT, offset = 20u)]
}

//! Built by hand: each pipeline names the rendering's three color attachments and carries its pass's maps on
//! the create info's pNext chain (``VkPipelineRenderingCreateInfo`` -> ``VkRenderingAttachmentLocationInfo`` ->
//! ``VkRenderingInputAttachmentIndexInfo``); the chain is linked here, in the frame the create info lives in.
def build_pass_pipeline(device : Device; layout : PipelineLayout; vert, frag : ShaderModule; kind : PassKind) : Pipeline {   // nolint:STYLE038 - flat one-call-per-item Vulkan setup run
    let is_geometry_pass = kind == PassKind.geometry
    var stages : array<VkPipelineShaderStageCreateInfo>
    stages |> resize(2)
    stages[0] = VkPipelineShaderStageCreateInfo()
    stages[0].stage.vertex = true
    stages[0].module_ = boost_value_to_vk(vert)
    stages[0].pName = "main"
    stages[1] = VkPipelineShaderStageCreateInfo()
    stages[1].stage.fragment = true
    stages[1].module_ = boost_value_to_vk(frag)
    stages[1].pName = "main"

    var binding = VkVertexInputBindingDescription()
    binding.binding = 0u
    binding.stride = CUBE_VERTEX_STRIDE
    binding.inputRate = VkVertexInputRate.VERTEX
    var attrs <- cube_vertex_attributes()
    var vinput = VkPipelineVertexInputStateCreateInfo()
    if (is_geometry_pass) {
        vinput.vertexBindingDescriptionCount = 1u
        vinput.vertexAttributeDescriptionCount = uint(length(attrs))
    }
    var ia = VkPipelineInputAssemblyStateCreateInfo()
    ia.topology = VkPrimitiveTopology.TRIANGLE_LIST

    var viewport = VkViewport()
    viewport.width = float(V14_W)
    viewport.height = float(V14_H)
    viewport.maxDepth = 1.0
    var scissor : VkRect2D
    scissor.extent.width = uint(V14_W)
    scissor.extent.height = uint(V14_H)
    var vp = VkPipelineViewportStateCreateInfo()
    vp.viewportCount = 1u
    vp.scissorCount = 1u

    var rast = VkPipelineRasterizationStateCreateInfo()
    rast.polygonMode = VkPolygonMode.FILL
    rast.cullMode.back = is_geometry_pass
    rast.frontFace = VkFrontFace.COUNTER_CLOCKWISE
    rast.lineWidth = 1.0
    var msaa = VkPipelineMultisampleStateCreateInfo()
    msaa.rasterizationSamples._1 = true

    // the geometry pass tests and writes depth; the lighting pass is a fullscreen triangle with depth off
    var depthstate = VkPipelineDepthStencilStateCreateInfo()
    depthstate.depthTestEnable = is_geometry_pass ? VK_TRUE : VK_FALSE
    depthstate.depthWriteEnable = is_geometry_pass ? VK_TRUE : VK_FALSE
    depthstate.depthCompareOp = VkCompareOp.LESS

    // one blend attachment per attachment of the rendering, written by this pass or not
    var blend_atts : array<VkPipelineColorBlendAttachmentState>
    blend_atts |> resize(int(N_COLOR_ATTACHMENTS))
    for (att in blend_atts) {
        att.colorWriteMask = VkColorComponentFlags.r | VkColorComponentFlags.g | VkColorComponentFlags.b | VkColorComponentFlags.a
    }
    var blend = VkPipelineColorBlendStateCreateInfo()
    blend.attachmentCount = N_COLOR_ATTACHMENTS

    var color_fmts <- [ALBEDO_FMT, NORMAL_FMT, COLOR_FMT]
    var locations := is_geometry_pass ? GBUF_LOCATIONS : LIGHT_LOCATIONS
    var inputs := is_geometry_pass ? GBUF_INPUTS : LIGHT_INPUTS
    var rendering = VkPipelineRenderingCreateInfo()
    rendering.colorAttachmentCount = N_COLOR_ATTACHMENTS
    rendering.depthAttachmentFormat = DEPTH_FMT
    var loc_info = VkRenderingAttachmentLocationInfo()
    loc_info.colorAttachmentCount = N_COLOR_ATTACHMENTS
    var in_info = VkRenderingInputAttachmentIndexInfo()
    in_info.colorAttachmentCount = N_COLOR_ATTACHMENTS

    var h : VkPipeline
    unsafe {
        rendering.pColorAttachmentFormats = addr(color_fmts[0])
        loc_info.pColorAttachmentLocations = addr(locations[0])
        in_info.pColorAttachmentInputIndices = addr(inputs[0])
        rendering.pNext = addr<void?>(loc_info)
        loc_info.pNext = addr<void?>(in_info)
        var gp = VkGraphicsPipelineCreateInfo()
        gp.pNext = addr<void?>(rendering)
        gp.stageCount = 2u
        gp.pStages = addr(stages[0])
        vinput.pVertexBindingDescriptions = addr(binding)
        vinput.pVertexAttributeDescriptions = addr(attrs[0])
        gp.pVertexInputState = addr(vinput)
        gp.pInputAssemblyState = addr(ia)
        vp.pViewports = addr(viewport)
        vp.pScissors = addr(scissor)
        gp.pViewportState = addr(vp)
        gp.pRasterizationState = addr(rast)
        gp.pMultisampleState = addr(msaa)
        gp.pDepthStencilState = addr(depthstate)
        blend.pAttachments = addr(blend_atts[0])
        gp.pColorBlendState = addr(blend)
        gp.layout = boost_value_to_vk(layout)
        vk_check(vkCreateGraphicsPipelines(boost_value_to_vk(device), null, 1u, addr(gp), null, addr(h)), null)
    }
    delete stages
    delete attrs
    delete blend_atts
    delete color_fmts
    delete locations
    delete inputs
    var b = vk_value_to_boost(h)
    b._needs_delete = true
    b._device = unsafe(reinterpret<uint64>(boost_value_to_vk(device)))
    return <- b
}

//! Everything the frame needs that depends only on (device, phys): the G-buffer and color targets, the
//! cube geometry, the camera UBO, the three host-copied textures, both pipelines on push-descriptor set
//! layouts, and a host-visible readback. No descriptor pool anywhere.
struct public Vulkan14Resources {
    albedo            : OffscreenTarget
    normal            : OffscreenTarget
    color             : OffscreenTarget
    depth             : OffscreenDepth
    vb                : HostBuffer
    ib                : HostBuffer
    ubo               : HostBuffer
    textures          : array<OffscreenTarget>
    tex_layout        : VkImageLayout       // the layout host_copy_layout chose; the pushed descriptor names it
    sampler           : Sampler
    gbuf_layouts      : array<DescriptorSetLayout>
    gbuf_pipe_layout  : PipelineLayout
    gbuf_pipeline     : Pipeline
    light_layouts     : array<DescriptorSetLayout>
    light_pipe_layout : PipelineLayout
    light_pipeline    : Pipeline
    readback          : HostBuffer
    buf_size          : uint64
}

def public finalize(var r : Vulkan14Resources) {
    delete r.readback
    delete r.light_pipeline
    delete r.light_pipe_layout
    delete r.light_layouts
    delete r.gbuf_pipeline
    delete r.gbuf_pipe_layout
    delete r.gbuf_layouts
    delete r.sampler
    delete r.textures
    delete r.ubo
    delete r.ib
    delete r.vb
    delete r.depth
    delete r.color
    delete r.normal
    delete r.albedo
}

//! The scene's resources on a caller-owned device from `create_device_vulkan14` (the context's or the viewer's).
def public build_vulkan14_resources(device : Device; phys : VkPhysicalDevice) : Vulkan14Resources {
    let gbuf_usage = VkImageUsageFlags.input_attachment          // read back inside the rendering by the lighting pass
    var inscope albedo <- build_offscreen_target(device, phys, V14_W, V14_H, ALBEDO_FMT, VkSampleCountFlags._1, gbuf_usage)
    var inscope normal <- build_offscreen_target(device, phys, V14_W, V14_H, NORMAL_FMT, VkSampleCountFlags._1, gbuf_usage)
    var inscope color <- build_offscreen_target(device, phys, V14_W, V14_H, COLOR_FMT)
    var inscope depth <- build_offscreen_depth(device, phys, V14_W, V14_H, DEPTH_FMT)

    var vb_usage : VkBufferUsageFlags
    vb_usage.vertex_buffer = true
    var verts_copy := cube_vertices
    var inscope vb <- create_host_buffer_from_bytes(device, phys, vb_usage, verts_copy)
    delete verts_copy
    var ib_usage : VkBufferUsageFlags
    ib_usage.index_buffer = true
    var indices_copy := cube_indices
    var inscope ib <- create_host_buffer_from_bytes(device, phys, ib_usage, indices_copy)
    delete indices_copy
    var ubo_usage : VkBufferUsageFlags
    ubo_usage.uniform_buffer = true
    var inscope ubo <- create_host_buffer(device, phys, CAMERA_UBO_BYTES, ubo_usage)

    // host image copy lands in SHADER_READ_ONLY_OPTIMAL where the device lists it, else GENERAL - both sample legally
    let tex_layout = host_copy_layout(phys, VkImageLayout.SHADER_READ_ONLY_OPTIMAL)
    var inscope textures : array<OffscreenTarget>
    textures |> reserve(N_CUBES)
    for (pattern in each(CubePattern.red_checker)) {
        var pixels <- gen_cube_texture(pattern)
        textures |> emplace(create_host_copied_texture(device, phys, pixels, tex_layout))
        delete pixels
    }
    let sci = SamplerCreateInfo(
        magFilter = VkFilter.NEAREST,
        minFilter = VkFilter.LINEAR,
        mipmapMode = VkSamplerMipmapMode.NEAREST,
        addressModeU = VkSamplerAddressMode.REPEAT,
        addressModeV = VkSamplerAddressMode.REPEAT,
        addressModeW = VkSamplerAddressMode.REPEAT,
        maxLod = 1.0f)
    var inscope sampler <- create_sampler(device, sci)

    // push-descriptor set layouts: the reflected bindings, flagged so the sets are never allocated
    var gbuf_refl <- [decode_reflection(gbuf_vert_spv_reflect), decode_reflection(gbuf_frag_spv_reflect)]
    var inscope gbuf_layouts <- build_push_descriptor_set_layouts(device, gbuf_refl)
    var inscope gbuf_pipe_layout <- build_pipeline_layout(device, gbuf_layouts, gbuf_refl)
    delete gbuf_refl
    var light_refl <- [decode_reflection(light_vert_spv_reflect), decode_reflection(light_frag_spv_reflect)]
    var inscope light_layouts <- build_push_descriptor_set_layouts(device, light_refl)
    var inscope light_pipe_layout <- build_pipeline_layout(device, light_layouts, light_refl)
    delete light_refl

    var inscope gbuf_vert <- create_shader_module(device, gbuf_vert_spv)
    var inscope gbuf_frag <- create_shader_module(device, gbuf_frag_spv)
    var inscope light_vert <- create_shader_module(device, light_vert_spv)
    var inscope light_frag <- create_shader_module(device, light_frag_spv)
    var inscope gbuf_pipeline <- build_pass_pipeline(device, gbuf_pipe_layout, gbuf_vert, gbuf_frag, PassKind.geometry)
    var inscope light_pipeline <- build_pass_pipeline(device, light_pipe_layout, light_vert, light_frag, PassKind.lighting)

    let buf_size = uint64(V14_W * V14_H * 4)
    var inscope readback <- create_host_buffer(device, phys, buf_size)

    return <- Vulkan14Resources(
        albedo <- albedo, normal <- normal, color <- color, depth <- depth,
        vb <- vb, ib <- ib, ubo <- ubo,
        textures <- textures, tex_layout = tex_layout, sampler <- sampler,
        gbuf_layouts <- gbuf_layouts, gbuf_pipe_layout <- gbuf_pipe_layout, gbuf_pipeline <- gbuf_pipeline,
        light_layouts <- light_layouts, light_pipe_layout <- light_pipe_layout, light_pipeline <- light_pipeline,
        readback <- readback, buf_size = buf_size)
}

8.15.16.4. The frame

record_vulkan14_frame moves the two G-buffer attachments into RENDERING_LOCAL_READ and the color target into COLOR_ATTACHMENT_OPTIMAL, then opens one rendering over all three plus depth. Inside it: the attachment maps for the geometry pass, the three cube draws (a push-constant model matrix and two pushed descriptors each), the by-region barrier, the maps for the lighting pass, two pushed input attachments, and the fullscreen draw.

//! Cube `i`'s camera UBO and texture, pushed into the command buffer - there is no set to allocate or update.
def push_cube_descriptors(res : Vulkan14Resources; cmd : CommandBuffer; i : int) {
    var w0 = WriteDescriptorSet(dstBinding = 0u, descriptorType = VkDescriptorType.UNIFORM_BUFFER, descriptorCount = 1u)
    w0.pBufferInfo |> push(DescriptorBufferInfo(buffer = weak_copy(res.ubo.buffer), range_ = CAMERA_UBO_BYTES))
    var w1 = WriteDescriptorSet(dstBinding = 1u, descriptorType = VkDescriptorType.COMBINED_IMAGE_SAMPLER, descriptorCount = 1u)
    w1.pImageInfo |> push(DescriptorImageInfo(sampler = weak_copy(res.sampler), imageView = weak_copy(res.textures[i].view),
        imageLayout = res.tex_layout))
    var writes <- [ <- w0, <- w1]
    cmd_push_descriptor_set(cmd, VkPipelineBindPoint.GRAPHICS, res.gbuf_pipe_layout, 0u, writes)
    unsafe { delete writes }
}

//! Albedo and normal as input attachments in RENDERING_LOCAL_READ, the layout the rendering holds them in.
def push_light_descriptors(res : Vulkan14Resources; cmd : CommandBuffer) {
    var writes : array<WriteDescriptorSet>
    writes |> reserve(2)
    for (binding, target in [0u, 1u], [weak_copy(res.albedo.view), weak_copy(res.normal.view)]) {
        var w = WriteDescriptorSet(dstBinding = binding, descriptorType = VkDescriptorType.INPUT_ATTACHMENT, descriptorCount = 1u)
        w.pImageInfo |> push(DescriptorImageInfo(imageView = weak_copy(target), imageLayout = VkImageLayout.RENDERING_LOCAL_READ))
        writes |> emplace(w)
    }
    cmd_push_descriptor_set(cmd, VkPipelineBindPoint.GRAPHICS, res.light_pipe_layout, 0u, writes)
    unsafe { delete writes }
}

//! The command-buffer twin of the pipeline's maps: fragment location -> attachment, attachment -> ``subpassInput`` index.
def set_pass_maps(cmd : CommandBuffer; locations, inputs : array<uint>) {
    var loc : RenderingAttachmentLocationInfo
    loc.pColorAttachmentLocations := locations
    cmd_set_rendering_attachment_locations(cmd, loc)
    delete loc
    var inp : RenderingInputAttachmentIndexInfo
    inp.pColorAttachmentInputIndices := inputs
    cmd_set_rendering_input_attachment_indices(cmd, inp)
    delete inp
}

//! Record the frame: the G-buffer attachments move to RENDERING_LOCAL_READ (written as attachments and read
//! as input attachments in that one layout), then ONE rendering holds both passes - geometry into attachments
//! 0 and 1, a by-region barrier, lighting into attachment 2. Ends with the lit color in TRANSFER_SRC_OPTIMAL.
def public record_vulkan14_frame(res : Vulkan14Resources; cmd : CommandBuffer; time : float) {
    let local_read_stages = VkPipelineStageFlags2.color_attachment_output | VkPipelineStageFlags2.fragment_shader
    let local_read_access = VkAccessFlags2.color_attachment_write | VkAccessFlags2.input_attachment_read
    // every attachment starts the frame UNDEFINED: the previous frame's reads finished at its queue-idle wait
    transition_image2(cmd, res.albedo.image, VkImageLayout.UNDEFINED, VkImageLayout.RENDERING_LOCAL_READ,
        PIPELINE_STAGE_2_NONE, ACCESS_2_NONE, local_read_stages, local_read_access)
    transition_image2(cmd, res.normal.image, VkImageLayout.UNDEFINED, VkImageLayout.RENDERING_LOCAL_READ,
        PIPELINE_STAGE_2_NONE, ACCESS_2_NONE, local_read_stages, local_read_access)
    transition_image2(cmd, res.color.image, VkImageLayout.UNDEFINED, VkImageLayout.COLOR_ATTACHMENT_OPTIMAL,
        PIPELINE_STAGE_2_NONE, ACCESS_2_NONE,
        VkPipelineStageFlags2.color_attachment_output, VkAccessFlags2.color_attachment_write)
    transition_depth_image2(cmd, res.depth.image, VkImageLayout.UNDEFINED, VkImageLayout.DEPTH_ATTACHMENT_OPTIMAL,
        PIPELINE_STAGE_2_NONE, ACCESS_2_NONE,
        VkPipelineStageFlags2.early_fragment_tests | VkPipelineStageFlags2.late_fragment_tests,
        VkAccessFlags2.depth_stencil_attachment_write)

    // the G-buffer attachments are transient: cleared on entry, read inside, never stored
    var atts <- [
        <- RenderingAttachmentInfo(imageView = weak_copy(res.albedo.view), imageLayout = VkImageLayout.RENDERING_LOCAL_READ,
            loadOp = VkAttachmentLoadOp.CLEAR, storeOp = VkAttachmentStoreOp.DONT_CARE, clearValue = clear_color(0.0f, 0.0f, 0.0f, 0.0f)),
        <- RenderingAttachmentInfo(imageView = weak_copy(res.normal.view), imageLayout = VkImageLayout.RENDERING_LOCAL_READ,
            loadOp = VkAttachmentLoadOp.CLEAR, storeOp = VkAttachmentStoreOp.DONT_CARE, clearValue = clear_color(0.5f, 0.5f, 0.5f, 0.0f)),
        <- rendering_color_attachment(res.color.view, clear_color(0.0f, 0.0f, 0.0f, 1.0f))]
    var depth_att <- rendering_depth_attachment(res.depth.view, clear_depth(1.0f))
    record_rendering(cmd, full_area(V14_W, V14_H), 1u, atts, depth_att) {
        set_pass_maps(cmd, GBUF_LOCATIONS, GBUF_INPUTS)
        cmd_bind_pipeline(cmd, res.gbuf_pipeline)
        var vbufs <- [weak_copy(res.vb.buffer)]
        var voffs <- [0ul]
        cmd_bind_vertex_buffers(cmd, 0u, vbufs, voffs)
        delete vbufs
        delete voffs
        cmd_bind_index_buffer(cmd, weak_copy(res.ib.buffer), 0ul, VkIndexType.UINT16)
        for (i in range(N_CUBES)) {
            pc.model = model_matrix(time * (0.6f + 0.3f * float(i)), float3(1.7f * float(i - 1), 0.0f, 0.0f))
            gbuf_vs_push_constants(cmd, res.gbuf_pipe_layout)
            push_cube_descriptors(res, cmd, i)
            cmd_draw_indexed(cmd, uint(N_INDICES), 1u, 0u, 0, 0u)
        }
        // the hand-off: attachment writes -> input-attachment reads, per region, inside the rendering
        var dep : DependencyInfo
        dep.dependencyFlags.by_region = true
        dep.pMemoryBarriers |> emplace(MemoryBarrier2(
            srcStageMask = VkPipelineStageFlags2.color_attachment_output, srcAccessMask = VkAccessFlags2.color_attachment_write,
            dstStageMask = VkPipelineStageFlags2.fragment_shader, dstAccessMask = VkAccessFlags2.input_attachment_read))
        cmd_pipeline_barrier2(cmd, dep)
        delete dep
        set_pass_maps(cmd, LIGHT_LOCATIONS, LIGHT_INPUTS)
        cmd_bind_pipeline(cmd, res.light_pipeline)
        light_fs_push_constants(cmd, res.light_pipe_layout)
        push_light_descriptors(res, cmd)
        cmd_draw(cmd, 3u)
    }
    delete atts
    delete depth_att
    transition_image2(cmd, res.color.image, VkImageLayout.COLOR_ATTACHMENT_OPTIMAL, VkImageLayout.TRANSFER_SRC_OPTIMAL,
        VkPipelineStageFlags2.color_attachment_output, VkAccessFlags2.color_attachment_write,
        VkPipelineStageFlags2.copy | VkPipelineStageFlags2.blit, VkAccessFlags2.transfer_read)
}

8.15.16.5. Self-verifying

The oracle renders one frame at time 0 and samples it: the sky at the frame’s edge is the lighting pass’s gradient (not the cleared black of the third attachment), each cube shows the hue of the texture host image copy uploaded, and the top face under the light is brighter than the side face turned away from it – which only the normal attachment, read back through local read, can produce. It skips where vulkan14_supported is false.

[test]
def test_vulkan14_scene(t : T?) {
    if (!vulkan14_available()) {
        feint("Vulkan 1.4 with pushDescriptor + hostImageCopy + dynamicRenderingLocalRead not available; skipping (lavapipe reports 1.4 from Mesa 25.0)\n")
        return
    }
    var p <- render_vulkan14_scene(0.0f)
    t |> success(length(p) == V14_W * V14_H * 4, "one RGBA8 frame came back")

    // sky: the lighting pass wrote its gradient where no cube is - lighter at the top than the bottom
    let sky_top = px(p, 20, 10)
    let sky_bottom = px(p, 20, V14_H - 10)
    t |> success(sky_top.z > sky_top.x && sky_top.z > 40, "sky is the lighting pass's blue gradient, not the cleared black")
    t |> success(sky_top.z > sky_bottom.z, "sky is brighter at the top")

    // the three cubes sit left, center, right at time 0 (camera on +Z looking at the origin); each block
    // sits on a front face, and the block mean carries the texture's hue
    let left = avg_px(p, 180, 257)
    let mid = avg_px(p, 384, 257)
    let right = avg_px(p, 570, 257)
    t |> success(left.x > left.y + 30 && left.x > left.z + 30, "left cube carries the red checker texture")
    t |> success(mid.y > mid.x + 30 && mid.y > mid.z + 30, "center cube carries the green stripes texture")
    t |> success(right.z > right.x + 20 && right.z > right.y + 20, "right cube carries the blue dots texture")

    // lighting read the normal attachment: on the right cube, the top face under the light is brighter
    // than the -X face the time-0 light direction leaves in ambient
    let top_face = avg_px(p, 592, 195)
    let away_face = avg_px(p, 508, 245)
    t |> success(top_face.x + top_face.y + top_face.z > away_face.x + away_face.y + away_face.z + 100,
        "the face under the light is lit brighter than the one turned away from it")
    delete p
}

8.15.16.6. Running it

# the CI pixel-oracle gate (lavapipe from Mesa 25.0 in CI, a 1.4 GPU locally)
daslang.exe dastest/dastest.das -- --test modules/dasVulkan/tutorials/16_vulkan14

# watch it live in a window (needs the glfw module, a display and a 1.4 driver)
daslang.exe modules/dasVulkan/tutorials/16_vulkan14/window/show_vulkan14.das

# regenerate the recording (needs stbimage + audio + ffmpeg + dasHV, and Kokoro TTS at :8880, locally)
daslang.exe modules/dasVulkan/tutorials/16_vulkan14/recording/record_vulkan14.das

8.15.16.7. Beside the tutorials

examples/shader_objects.das draws the hello triangle with no pipeline at all: VK_EXT_shader_object binds a shader object per stage and takes every piece of fixed-function state from the command buffer (cmd_set_shader_object_baseline sets what a device from create_device_shader_object requires). It is an opt-in beside the pipeline path – an extension in every Vulkan version, absent on Android and partial on MoltenVK – and tests/integration/test_shader_objects.das checks it draws the same triangle.