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
VkImagewitharrayLayers = 6andflags.cube_compatible = true, viewed asVkImageViewType.CUBE;``samplerCube`` in the shader – dasSpirv emits an
OpTypeImagewithDim = Cube, andtexture(samplerCube, vec3)lowers to the sameOpImageSampleImplicitLodyou 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; onevkCmdCopyBufferToImagewith 6 per-layer regions uploads the whole cubemap;The depth=1.0 trick –
gl_Position.z = gl_Position.w(rebuilt via the float4 constructor since dasSpirv has no write-swizzle) makes every skybox fragment land at NDCz = 1.0(the far plane). Combined withLESS_OR_EQUALdepth 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.