06 - Skybox: A Procedural Cubemap Sky

The first five tutorials rendered geometry placed in front of the camera. This one fills everything behind it: a cubemap skybox surrounds the camera at infinity, sampled by a 3D direction vector. The headline rails:

  • VK_IMAGE_VIEW_TYPE_CUBE – one VkImage with arrayLayers = 6 and flags.cube_compatible = true, viewed as VkImageViewType.CUBE;

  • ``samplerCube`` in the shader – dasSpirv emits an OpTypeImage with Dim = Cube, and texture(samplerCube, vec3) lowers to the same OpImageSampleImplicitLod you saw in tutorial 04, with a 3-vector direction instead of a 2D UV;

  • Six procedural faces, one continuous sky – the host evaluates one sample_sky(direction) function across all six face patches. The faces pack contiguously into a single staging buffer; one vkCmdCopyBufferToImage with 6 per-layer regions uploads the whole cubemap;

  • The depth=1.0 trickgl_Position.z = gl_Position.w (rebuilt via the float4 constructor since dasSpirv has no write-swizzle) makes every skybox fragment land at NDC z = 1.0 (the far plane). Combined with LESS_OR_EQUAL depth test + depthWriteEnable = false, the skybox passes against a depth buffer cleared to 1.0 but never updates depth, so a future foreground pass in the same render pass will properly draw in front of it without modification;

  • Rotation-only view – the host strips the translation column from the view matrix so the cube stays glued to the camera. Camera position cancels; the skybox always appears at infinity.

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 music bed. The camera completes one full orbit and the sun glow tracks across the sky as it does. See skills/recording.md. To watch it live on your own GPU, run the windowed viewer (see See it live below).

The shaders

The vertex shader passes the cube position along as the per-fragment sampling direction (v_dir = a_pos). Standard proj * view * vec4(pos, 1), then the float4 is rebuilt with z = w to snap NDC z to 1.0 after the perspective divide. The fragment shader normalises the interpolated direction and feeds it to texture(sky_tex, dir) – the cubemap face + intra-face UVs are computed by the sampler from this 3-vector. [vulkan_*_shader] synthesises skybox_vs_bind_uniform from var @uniform cam : Camera; there is no push constant – the skybox is a static cubemap, animation comes from the camera moving in the host.

module skybox_tut_shaders public

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

// ===== shared UBO =====

//! Rotation-only view matrix (translation stripped) plus the perspective projection. The
//! translation strip keeps the skybox cube centred on the camera -- the skybox appears at
//! infinity, no matter where the camera moves.
struct Camera {
    view      : float4x4   // rotation-only view (keeps the skybox glued to the camera)
    proj      : float4x4
    view_full : float4x4   // full view incl. translation (the sphere + floor live in the world)
    cam_pos   : float4     // xyz = world camera position (for the reflection view vector)
    material  : float4     // rgb = metal tint, a = reflectivity — cycled over time for variety
}
var @uniform @set = 0 @binding = 0 cam : Camera

// ===== cubemap binding =====

//! 6-face cubemap of the procedural sky (sun + gradient + horizon). VK_IMAGE_VIEW_TYPE_CUBE on the
//! host side; ``samplerCube`` here makes dasSpirv emit an ``OpTypeImage`` with ``Dim=Cube``.
var @set = 0 @binding = 1 sky_tex : samplerCube

// ===== vertex shader =====

var @in @location = 0 a_pos : float3
var @out @location = 0 v_dir : float3

[vulkan_vertex_shader(name="skybox_vert_spv")]
def skybox_vs {
    // Pass the local cube position as the per-fragment sampling direction. Because we render a unit
    // cube centred at the origin and the view is rotation-only, the position is the world-space
    // direction from camera to vertex.
    v_dir = a_pos
    // Standard rotation+projection. Then snap z to w so the post-divide NDC z = 1.0 (the far plane).
    // dasSpirv does not support write-swizzle (clip.z = clip.w), so rebuild the vec4 via constructor.
    let clip = cam.proj * cam.view * float4(a_pos, 1.0)
    gl_Position = float4(clip.x, clip.y, clip.w, clip.w)
}

// ===== fragment shader =====

var @in @location = 0 f_dir : float3
var @out @location = 0 frag_color : float4

[vulkan_fragment_shader(name="skybox_frag_spv")]
def skybox_fs {
    // ``f_dir`` is the interpolated cube position. Normalize to get the unit direction the cubemap
    // expects; the cube face + intra-face UV are computed by the sampler from this direction.
    let col = texture(sky_tex, normalize(f_dir)).rgb
    frag_color = float4(col, 1.0)
}

// ===== reflective sphere + floor =====
// Both live in the world (full view, not the rotation-only skybox view) and sample the SAME cubemap
// the skybox renders. One shared vertex shader; the fragment shaders diverge (metal vs checkerboard).

var @in @location = 1 a_normal : float3          // sphere/floor vertex normal (skybox ignores it)
var @out @location = 1 v_world_pos    : float3
var @out @location = 2 v_world_normal : float3

[vulkan_vertex_shader(name="scene_vert_spv")]
def scene_vs {
    v_world_pos = a_pos
    v_world_normal = a_normal
    gl_Position = cam.proj * cam.view_full * float4(a_pos, 1.0)
}

var @in @location = 1 f_world_pos    : float3
var @in @location = 2 f_world_normal : float3

//! GLSL-style reflect(I, N) = I - 2*dot(N,I)*N, with I the incident (eye->surface) direction.
def private reflect_dir(i, n : float3) : float3 {
    return i - n * (2.0 * dot(n, i))
}

//! Mirror-metal sphere. Reflect the view about the surface normal, sample the cubemap, and tint by
//! the cycled ``material`` (Schlick metal Fresnel: tinted reflectance at normal incidence ramping to
//! white at grazing). The host morphs ``material`` chrome -> gold -> copper -> steel over time.
[vulkan_fragment_shader(name="sphere_frag_spv")]
def sphere_fs {
    let n = normalize(f_world_normal)
    let v = normalize(float3(cam.cam_pos.x - f_world_pos.x, cam.cam_pos.y - f_world_pos.y,
                             cam.cam_pos.z - f_world_pos.z))
    let ndotv = max(dot(n, v), 0.0)
    let env = texture(sky_tex, reflect_dir(float3(-v.x, -v.y, -v.z), n)).rgb
    let tint = float3(cam.material.x, cam.material.y, cam.material.z)
    let f0 = tint * cam.material.w
    let fres = pow(1.0 - ndotv, 5.0)
    let reflectance = lerp(f0, float3(1.0, 1.0, 1.0), float3(fres, fres, fres))
    let col = env * reflectance
    frag_color = float4(col.x, col.y, col.z, 1.0)
}

//! Checkerboard floor with a faint Fresnel sky reflection so it reads as a polished surface grounding
//! the sphere (not a flat plane floating in the sky).
[vulkan_fragment_shader(name="floor_frag_spv")]
def floor_fs {
    let n = normalize(f_world_normal)
    let gx = floor(f_world_pos.x * 0.5 + 100.0)
    let gz = floor(f_world_pos.z * 0.5 + 100.0)
    // true checkerboard: cell parity (gx+gz) mod 2, via floor (no mod builtin needed)
    let cell = gx + gz
    let parity = cell - floor(cell * 0.5) * 2.0          // 0.0 = even cell, 1.0 = odd cell
    let checker = parity < 0.5 ? 0.85 : 0.28
    let base = float3(checker, checker, checker) * float3(0.72, 0.74, 0.80)
    let v = normalize(float3(cam.cam_pos.x - f_world_pos.x, cam.cam_pos.y - f_world_pos.y,
                             cam.cam_pos.z - f_world_pos.z))
    let env = texture(sky_tex, reflect_dir(float3(-v.x, -v.y, -v.z), n)).rgb
    let fres = pow(1.0 - max(dot(n, v), 0.0), 4.0) * 0.4
    let col = lerp(base, env, float3(fres, fres, fres))
    frag_color = float4(col.x, col.y, col.z, 1.0)
}

The render (headless)

The host builds the 6-face cubemap from one sample_sky(direction) function: zenith blue, warm horizon, dark ground hemisphere, and a sun disc + Mie-style glow along SUN_DIR. Each face’s pixel-to-direction mapping follows the Vulkan cubemap face order (+X, -X, +Y, -Y, +Z, -Z = layers 0..5), so the same procedural function evaluated on each face produces a continuous sky with no visible seams.

def public update_skybox_uniforms(res : SkyboxResources; device : Device; time, camera_t : float) {
    let camera_angle = camera_t * 2.0f * PI
    let cam_x = cos(camera_angle) * 4.5f
    let cam_z = sin(camera_angle) * 4.5f
    let cam_y = 2.2f + sin(camera_angle * 2.0f) * 0.6f      // elevated, gentle sway
    let cam_eye = float3(cam_x, cam_y, cam_z)
    var view = look_at_rh(cam_eye, float3(0.0f, 0.9f, 0.0f), float3(0.0f, 1.0f, 0.0f))
    cam.view_full = view                                     // full view (COPY before strip_translation mutates `view`)
    cam.view = strip_translation(view)                       // skybox: rotation only (strips `view` in place)
    cam.proj = perspective_vk(70.0f * PI / 180.0f, float(SKY_W) / float(SKY_H), 0.1f, 50.0f)
    cam.cam_pos = float4(cam_eye.x, cam_eye.y, cam_eye.z, 0.0f)
    cam.material = cycle_metal(time)
    skybox_vs_bind_uniform(device, res.ubo.memory)          // writes the whole cam block
}

//! Record the skybox draw into the given command buffer. The render pass ends with the colour
//! attachment in TRANSFER_SRC_OPTIMAL (boost default), so the windowed driver can blit the result
//! straight onto the swapchain image.
def public record_skybox_render_pass(res : SkyboxResources; cmd : CommandBuffer) {
    var clears <- [clear_color(0.0f, 0.0f, 0.0f, 1.0f), clear_depth(1.0f)]
    record_render_pass(cmd, res.render_pass, res.framebuffer, full_area(SKY_W, SKY_H), clears) {
        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)
        var voffs : array<uint64>
        voffs |> push(0ul)

        // foreground first (depth write): reflective sphere, then checker floor
        cmd_bind_pipeline(cmd, res.sphere_pipeline)
        let sphere_vbufs <- [weak_copy(res.sphere_vb.buffer)]
        cmd_bind_vertex_buffers(cmd, 0u, sphere_vbufs, voffs)
        cmd_bind_index_buffer(cmd, weak_copy(res.sphere_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_draw_indexed(cmd, uint(res.sphere_index_count), 1u, 0u, 0, 0u)

        cmd_bind_pipeline(cmd, res.floor_pipeline)
        let floor_vbufs <- [weak_copy(res.floor_vb.buffer)]
        cmd_bind_vertex_buffers(cmd, 0u, floor_vbufs, voffs)
        cmd_bind_index_buffer(cmd, weak_copy(res.floor_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_draw_indexed(cmd, uint(res.floor_index_count), 1u, 0u, 0, 0u)

        // skybox last: its z=1.0 trick + LESS_OR_EQUAL fills only where no foreground was drawn
        cmd_bind_pipeline(cmd, res.pipeline)
        let vbufs <- [weak_copy(res.vb.buffer)]
        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_skybox_frame runs update_skybox_uniforms + record_skybox_render_pass inside a run_cmd_sync, copies the colour attachment to the readback buffer, and clones it out. The pipeline is built with depth_write_enable=false so the depth=1.0 fragments don’t overwrite depth – a future combined scene can drop foreground geometry into the same pipeline and the skybox automatically fills the gaps.

def public render_skybox_frame(var ctx : SkyboxContext; time, camera_t : float) : array<uint8> {
    update_skybox_uniforms(ctx.res, ctx.device, time, camera_t)
    var pixels : array<uint8>
    run_cmd_sync(ctx.device, ctx.pool, ctx.queue) $(cmd) {
        record_skybox_render_pass(ctx.res, cmd)
        copy_image_to_buffer(cmd, ctx.res.color.image, ctx.res.readback, SKY_W, SKY_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, real GPU locally). At camera_t = 0.75, time = 0 the camera sits at (0, 0, -4) looking +Z, so the sun-bearing (+X, +Y, +Z) octant is in front. The oracle asserts a handful of structural properties: the mean luminance is well above the clear colour, somewhere in the frame is a warm sun-tone pixel (sun glow visible), somewhere is a blue-dominant zenith pixel (zenith band sampled), and the top band is brighter on average than the bottom band (ground darker than sky).

[test]
def test_skybox_oracle(t : T?) {
    var pixels <- render_skybox(TEST_TIME, TEST_CAM)

    // 1. not all clear
    var sum_lum = 0
    let stride = 16
    for (j in range(SKY_H / stride)) {
        for (i in range(SKY_W / stride)) {
            let p = px(pixels, i * stride, j * stride)
            sum_lum += p.x + p.y + p.z
        }
    }
    let n = (SKY_W / stride) * (SKY_H / stride)
    let mean_lum = sum_lum / (n * 3)
    t |> success(mean_lum > 50, "mean luminance > 50 (scene rendered, got {mean_lum})")

    // 2. bright sun-glow pixel (near-white) somewhere in the top half of the sky
    var saw_sun = false
    for (j in range(48)) {
        for (i in range(96)) {
            let p = px(pixels, i * (SKY_W / 96), j * (SKY_H / 96))
            if (p.x + p.y + p.z > 640 && p.x > 200 && p.y > 200) {
                saw_sun = true
            }
        }
    }
    t |> success(saw_sun, "found a bright sun-glow pixel in the sky")

    // 3. reflective sphere in the centre: a saturated-blue pixel (upper cap reflects the zenith, more
    //    saturated than the pale sky) AND a near-black pixel (lower cap reflects the dark ground).
    var saw_sphere_blue = false
    var saw_sphere_dark = false
    let sy0 = SKY_H * 3 / 10
    let sx0 = SKY_W * 38 / 100
    for (jj in range(SKY_H * 3 / 10)) {            // scans rows sy0 .. sy0 + 30%H (the sphere band)
        let j = sy0 + jj
        for (ii in range(SKY_W * 24 / 100)) {      // cols sx0 .. sx0 + 24%W (the central column)
            let p = px(pixels, sx0 + ii, j)
            if (p.z > p.x + 35 && p.z > 120) { saw_sphere_blue = true }
            if (p.x + p.y + p.z < 90) { saw_sphere_dark = true }
        }
    }
    t |> success(saw_sphere_blue && saw_sphere_dark,
                 "reflective sphere present (blue zenith + dark ground reflection in centre)")

    // 4. checkerboard floor: the bottom third must show both a light-tile and a dark-tile pixel. The sRGB
    // format gamma-encodes the linear output, brightening both (light ~620-635, dark ~370-400; linear was
    // ~430+ and ~60-240), but the clusters stay ~230 lum apart, so contrast detection is still valid.
    var saw_light_tile = false
    var saw_dark_tile = false
    let fy0 = SKY_H * 7 / 10
    for (jj in range((SKY_H - fy0) / 8)) {
        let j = fy0 + jj * 8
        for (ii in range(SKY_W / 8)) {
            let lum = get_lum(px(pixels, ii * 8, j))
            if (lum > 560) { saw_light_tile = true }
            if (lum > 200 && lum < 460) { saw_dark_tile = true }
        }
    }
    t |> success(saw_light_tile && saw_dark_tile, "checkerboard floor present (light + dark tiles)")

    delete pixels
}

def get_lum(p : int3) : int {
    return p.x + p.y + p.z
}

See it live

window/show_skybox.das opens a GLFW window with a Vulkan swapchain and presents the orbiting skybox every frame. It owns its own instance (with surface extensions) + device (with VK_KHR_swapchain), then calls build_skybox_resources to share the offscreen render pass, framebuffer, cubemap and pipeline with the headless oracle. Each frame it runs update_skybox_uniforms + record_skybox_render_pass into the present command buffer, then blits the colour attachment onto the swapchain image.

require glfw/glfw_boost
require vulkan
require vulkan/vulkan_boost
require vulkan/vulkan_window
require ../skybox_tut.das
require daslib/defer
require math

[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(SKY_W, SKY_H, "dasVulkan tutorial 06 - skybox (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 06 (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_skybox_resources(device, phys, queue, pool)
    var inscope swap <- create_swapchain(device, phys, surface, SKY_W, SKY_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())
        // 30-second camera orbit -- one full circle per 30s of wall clock.
        let raw_cam = t * 0.0333f
        let camera_t = raw_cam - floor(raw_cam)
        update_skybox_uniforms(res, device, t, camera_t)

        let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
            record_skybox_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 = SKY_W
            region.srcOffsets[1].y = SKY_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/06_skybox

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

# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/06_skybox/recording/record_skybox.das

Next

07 - Particles: A Compute-Driven Swarm hands the vertex stream itself to a compute shader: one VkBuffer lives as a storage-buffer SSBO for compute and as a per-vertex stream for graphics, with a single pipeline barrier between the passes. The first GPU-driven scene of the series.