11 - HDR + Bloom: Karis Pyramid + ACES
Tutorials 1-10 each introduced a frame’s content rail (geometry, lighting, MRT, subpass inputs). This one is the post-process rail: the same instanced cube swarm you saw in 05 - Instancing: A Thousand Cubes (one draw call), but rendered into a floating-point offscreen attachment with values above 1.0 – and then a Karis-style five-level bloom pyramid + ACES tonemap composite gives the bright cubes a soft glow into the dark margins.
The headline rails:
HDR offscreen target (R16G16B16A16_SFLOAT). The scene pass writes the lit colour directly into 16-bit float, no premature clamp at the end of the fragment. Emissive cubes (every seventh instance) multiply their base colour by
EMISSIVE_BOOST = 8.0, so the linear RGB lands in [4, 8] – well above the bright-pass threshold. Non-emissive cubes stay in [0, 1] and tonemap directly.Bright pass with Frostbite soft knee. The first post-process pass reads the HDR target, computes BT.709 luminance, and applies a smooth threshold curve:
t = clamp((luma - threshold + knee) / (2 * knee))followed by Hermite interpolation. Hardstep()thresholds produce obvious aliasing on moving emissives; the soft knee blends a fraction of almost-bright pixels.5-level Karis-style downsample pyramid. Five mip resolutions – 1/2, 1/4, 1/8, 1/16, 1/32 of the source – each generated by a five-sample weighted-bilinear filter (Kawase / Karis-bilinear): one centre sample weighted 0.5, four diagonal samples weighted 0.125 each. Bilinear filtering means each diagonal sample covers a 2x2 box, so five samples on the GPU touch a 16-texel footprint of the input.
3x3 tent upsample with additive blend. Going back UP the pyramid (mip 4 → 3, 3 → 2, 2 → 1, 1 → 0), a nine-tap 1-2-1 / 2-4-2 / 1-2-1 tent filter samples the smaller mip and the blend state adds it on top of the larger mip (
srcColorBlendFactor = ONE,dstColorBlendFactor = ONE). The render pass usesloadOp = LOADso the previously-downsampled contents survive.ACES tonemap composite. The final pass reads HDR scene + bloom mip 0, sums them with a configurable intensity, applies Krzysztof Narkowicz’s fitted-ACES approximation (a single rational polynomial), gamma 1/2.2 encodes for the sRGB-target backbuffer, and writes the LDR result.
Eleven render-pass instances per frame. One scene, one bright, four downsample, four upsample, one composite – all routed through three shared render-pass objects (color+depth scene, color-clear HDR, color-load HDR) and five pipelines (scene draws, bright/down fullscreen, up with additive blend, composite fullscreen). The three bloom pipelines – bright, down, up – use dynamic viewport because the same pipeline runs at every mip resolution; the composite pipeline is fixed at the LDR target size.
Every line of every shader is daslang, lowered to SPIR-V at compile time.
The clip above is the headless recording: 30 seconds, 30 fps, captured
into an APNG and ffmpeg-muxed with a daStrudel music bed. Watch the emissive cubes bloom into soft halos against the
deep purple background. The [test] checks structural signal at a
fixed frame – background corners stay dark, at least one near-saturated
emissive cube exists, and the histogram tail shows the bloom halo
dilation. To see the same scene live on your own GPU, run the windowed
viewer (see See it live below).
The shaders
Five fragment shaders + two vertex shaders. The scene vertex shader is the instanced cube swarm from tutorial 05, extended with an emissive per-instance attribute. The fullscreen vertex shader is the standard big-triangle trick (one shader, four passes). The fragment shaders are the headline rails: bright, downsample, upsample, composite (with ACES tonemap).
module hdr_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
// ===== scene UBO =====
//! Shared scene constants -- view / projection matrices, camera world position packed with time.
struct Camera {
view : float4x4
proj : float4x4
cam_time : float4 // xyz = camera world position, w = time in seconds
}
var @uniform @set = 0 @binding = 0 cam : Camera
// ===== push constants =====
//! One shared push-constant block. Different passes read different fields; daslang's static_if isn't
//! at play -- the SPIR-V emitter sees every load, but Vulkan happily ignores writes to fields that
//! no draw reads. Keeping a single block keeps the descriptor layout and pipeline-layout count tiny.
struct BloomCtrl {
src_rcp : float2 // 1.0 / source-mip dimensions, used by DOWN + UP for sample offsets
threshold : float // bright-pass: pixels with luma < threshold are killed
soft_knee : float // bright-pass: soft-knee width around threshold (0 = hard cut)
bloom_intensity : float // composite: how loud bloom is over the HDR scene
}
var @push_constant pc : BloomCtrl
// ===== SCENE pass =====
// per-vertex (binding 0): pos + normal
var @in @location = 0 a_pos : float3
var @in @location = 1 a_normal : float3
// per-instance (binding 1): offset + colour + phase + emissive (1.0 if cube should glow, 0.0 else)
var @in @location = 2 a_offset : float3
var @in @location = 3 a_color : float3
var @in @location = 4 a_phase : float
var @in @location = 5 a_emissive : float
var @out @location = 0 v_world_pos : float3
var @out @location = 1 v_world_normal : float3
var @out @location = 2 v_color : float3
var @out @location = 3 v_emissive : float
[vulkan_vertex_shader(name="hdr_scene_vert_spv")]
def hdr_scene_vs {
let t = cam.cam_time.w
let breathe = 0.7 + 0.2 * sin(t * 1.5 + a_phase * 6.2832)
let local = a_pos * breathe
let ang = t * 0.25 + a_phase * 6.2832
let c = cos(ang)
let s = sin(ang)
let rotated = float3(c * local.x - s * local.z, local.y, s * local.x + c * local.z)
let world = rotated + a_offset
gl_Position = cam.proj * cam.view * float4(world, 1.0)
v_world_pos = world
v_world_normal = float3(c * a_normal.x - s * a_normal.z, a_normal.y, s * a_normal.x + c * a_normal.z)
v_color = a_color
v_emissive = a_emissive
}
var @in @location = 0 f_world_pos : float3
var @in @location = 1 f_world_normal : float3
var @in @location = 2 f_color : float3
var @in @location = 3 f_emissive : float
var @out @location = 0 frag_hdr : float4
let EMISSIVE_BOOST = 8.0 // 8x exposure -- well above the bright-pass knee
let LIT_AMBIENT = 0.25
let LIT_KEY = 0.7
[vulkan_fragment_shader(name="hdr_scene_frag_spv")]
def hdr_scene_fs {
let n = normalize(f_world_normal)
let v = normalize(cam.cam_time.xyz - f_world_pos)
let l = normalize(float3(0.5, 1.0, 0.3))
let key = max(dot(n, l), 0.0) * LIT_KEY + LIT_AMBIENT
let rim = pow(1.0 - max(dot(n, v), 0.0), 2.5)
// lit_lo stays in [0,1]; emissive cubes multiply by EMISSIVE_BOOST so the HDR target carries
// values up to ~8. The bright-pass shader will threshold these.
let lit_lo = f_color * key + float3(0.25, 0.35, 0.55) * rim * 0.4
let lit = lit_lo * (1.0 + f_emissive * (EMISSIVE_BOOST - 1.0))
frag_hdr = float4(lit, 1.0)
}
// ===== BRIGHT pass =====
// Fullscreen triangle: 3 unindexed vertices, gl_VertexIndex 0..2, no vertex buffer. The vertex
// outputs a `(x,y)` that covers the screen and a `uv` in [0,1].
var @out @location = 0 v_uv : float2
[vulkan_vertex_shader(name="hdr_fullscreen_vert_spv")]
def hdr_fullscreen_vs {
// Big triangle trick: (-1,-1), (3,-1), (-1,3) covers the unit square in NDC; UV derived from
// ndc as `(xy + 1) / 2`, so the visible [0,1] portion of the triangle maps linearly to [0,1] UV.
let xs = fixed_array(-1.0, 3.0, -1.0)
let ys = fixed_array(-1.0, -1.0, 3.0)
let i = gl_VertexIndex
let x = xs[i]
let y = ys[i]
gl_Position = float4(x, y, 0.0, 1.0)
v_uv = float2((x + 1.0) * 0.5, (y + 1.0) * 0.5)
}
var @uniform @set = 1 @binding = 0 src0 : sampler2D // bright reads HDR scene at set=1,binding=0
var @in @location = 0 f_uv : float2
var @out @location = 0 frag_bright : float4
//! Frostbite soft-knee: `t = clamp((luma - threshold + knee) / (2*knee), 0, 1); curve = t*t*(3-2*t)`
//! gives a Hermite blend between zero (luma << threshold) and one (luma > threshold + knee). The
//! standard fast-bloom hard cut is `step(threshold, luma)` and produces aliasing on moving emissives.
[vulkan_fragment_shader(name="hdr_bright_frag_spv")]
def hdr_bright_fs {
let hdr = textureLod(src0, f_uv, 0.0).xyz
let luma = dot(hdr, float3(0.2126, 0.7152, 0.0722)) // BT.709 luminance
let knee = max(pc.soft_knee, 0.0001)
let t = clamp((luma - pc.threshold + knee) / (2.0 * knee), 0.0, 1.0)
let curve = t * t * (3.0 - 2.0 * t)
// Partial-Karis weight `1/(1 + luma)` (Karis, "Next Generation Post Processing in Call of Duty
// Advanced Warfare", SIGGRAPH 2014, slide 142). Without it an 8x emissive cube writes raw HDR ~8.0
// into mip 0 and the bilinear read clamps to a hard white square; 8/9 ~= 0.89 keeps the pyramid in [0,1].
let karis_weight = 1.0 / (1.0 + luma)
frag_bright = float4(hdr * karis_weight * curve, 1.0)
}
// ===== DOWN pass (Kawase / Karis-bilinear, 5 samples) =====
var @out @location = 0 frag_down : float4
[vulkan_fragment_shader(name="hdr_down_frag_spv")]
def hdr_down_fs {
let o = pc.src_rcp // 1 source-texel offset in UV space
let c = textureLod(src0, f_uv, 0.0).xyz
let lt = textureLod(src0, f_uv + float2(-o.x, -o.y), 0.0).xyz
let rt = textureLod(src0, f_uv + float2( o.x, -o.y), 0.0).xyz
let lb = textureLod(src0, f_uv + float2(-o.x, o.y), 0.0).xyz
let rb = textureLod(src0, f_uv + float2( o.x, o.y), 0.0).xyz
// Centre weighted 0.5; the four diagonal samples 0.125 each.
// Each diagonal sample sits at +-1 texel of the source mip, so bilinear filter effectively
// averages a 2x2 box -- 5 samples cover a 16-texel footprint of the input.
let sum = c * 0.5 + (lt + rt + lb + rb) * 0.125
frag_down = float4(sum, 1.0)
}
// ===== UP pass (3x3 tent, additive blend) =====
var @out @location = 0 frag_up : float4
[vulkan_fragment_shader(name="hdr_up_frag_spv")]
def hdr_up_fs {
let o = pc.src_rcp
// 9-tap tent with 1-2-1 / 2-4-2 / 1-2-1 weights (sum = 16, divided out)
let s00 = textureLod(src0, f_uv + float2(-o.x, -o.y), 0.0).xyz
let s10 = textureLod(src0, f_uv + float2( 0.0, -o.y), 0.0).xyz
let s20 = textureLod(src0, f_uv + float2( o.x, -o.y), 0.0).xyz
let s01 = textureLod(src0, f_uv + float2(-o.x, 0.0), 0.0).xyz
let s11 = textureLod(src0, f_uv, 0.0).xyz
let s21 = textureLod(src0, f_uv + float2( o.x, 0.0), 0.0).xyz
let s02 = textureLod(src0, f_uv + float2(-o.x, o.y), 0.0).xyz
let s12 = textureLod(src0, f_uv + float2( 0.0, o.y), 0.0).xyz
let s22 = textureLod(src0, f_uv + float2( o.x, o.y), 0.0).xyz
let tent = ((s00 + s20 + s02 + s22) * (1.0 / 16.0) +
(s10 + s01 + s21 + s12) * (2.0 / 16.0) +
s11 * (4.0 / 16.0))
// Output alpha = 1; the host-side pipeline's blend state writes RGB additively
// (srcColor=ONE, dstColor=ONE) and the colourWriteMask drops alpha so the dst alpha stays
// pinned at the bright-pass value.
frag_up = float4(tent, 1.0)
}
// ===== COMPOSITE pass (HDR scene + bloom -> ACES tonemap -> sRGB LDR) =====
var @uniform @set = 1 @binding = 1 src_bloom : sampler2D
var @out @location = 0 frag_ldr : float4
//! ACES fitted approximation (Krzysztof Narkowicz 2015). Full ACES is a 4-matrix tone mapper; this fit
//! hits the same shoulder/toe shape with one rational polynomial -- cheap, and it keeps the filmic look
//! the bloom targets. daslang shaders need explicit float3 broadcasts (no implicit scalar broadcast).
def aces_fit(x : float3) : float3 {
let a = float3(2.51, 2.51, 2.51)
let b = float3(0.03, 0.03, 0.03)
let c = float3(2.43, 2.43, 2.43)
let d = float3(0.59, 0.59, 0.59)
let e = float3(0.14, 0.14, 0.14)
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), float3(0.0, 0.0, 0.0), float3(1.0, 1.0, 1.0))
}
[vulkan_fragment_shader(name="hdr_composite_frag_spv")]
def hdr_composite_fs {
let hdr = textureLod(src0, f_uv, 0.0).xyz
let bloom = textureLod(src_bloom, f_uv, 0.0).xyz
let merged = hdr + bloom * pc.bloom_intensity
let mapped = aces_fit(merged)
// Output LINEAR post-tonemap values in [0,1] -- the LDR target is R16G16B16A16_SFLOAT. Gamma comes
// at the *blit*, where the destination is sRGB: the swap target for the window (asserted in
// show_hdr.das), an R8G8B8A8_SRGB readback for the test. Free in hardware, ~12-bit dark, no banding.
frag_ldr = float4(mapped, 1.0)
}
The render (headless)
The host builds the HDR scene target, five bloom mip targets, the LDR
final target; three render passes; one framebuffer for the scene, two
per bloom mip (clear + load variants), one composite; five pipelines;
eleven descriptor sets. record_hdr_frame records the eleven
render-pass instances in order: scene -> bright -> 4× downsample -> 4×
upsample -> composite.
def public record_hdr_frame(res : HdrResources; cmd : CommandBuffer) { // nolint:STYLE038 - flat command-record run
let threshold = 1.0f
let soft_knee = 0.5f
let bloom_intensity = 0.12f
// ---- scene pass ----
var scene_clears <- [clear_color(0.02f, 0.0f, 0.04f, 1.0f), clear_depth(1.0f)]
record_render_pass(cmd, res.rp_scene, res.fb_scene, full_area(HDR_W, HDR_H), scene_clears) {
cmd_bind_pipeline(cmd, res.scene_pipeline)
let sets <- [vk_value_to_boost(res.scene_set)]
var no_dyn : array<uint>
cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.scene_pipe_layout, 0u, sets, no_dyn)
let vbufs <- [weak_copy(res.cube_vb.buffer), weak_copy(res.inst_buf.buffer)]
var voffs <- [0ul, 0ul]
cmd_bind_vertex_buffers(cmd, 0u, vbufs, voffs)
cmd_bind_index_buffer(cmd, weak_copy(res.cube_ib.buffer), 0ul, VkIndexType.UINT16)
cmd_draw_indexed(cmd, uint(N_INDICES), uint(N_INSTANCES), 0u, 0, 0u)
}
delete scene_clears
// ---- bright pass : HDR scene -> bloom[0] ----
var b0_clears <- [clear_color(0.0f, 0.0f, 0.0f, 1.0f)]
record_render_pass(cmd, res.rp_post_clear, res.fb_post_clear[0], full_area(BLOOM_W[0], BLOOM_H[0]), b0_clears) {
set_dyn_vp(cmd, BLOOM_W[0], BLOOM_H[0])
cmd_bind_pipeline(cmd, res.bright_pipeline)
let sets <- [vk_value_to_boost(res.bright_set)]
var no_dyn : array<uint>
cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.post_pipe_layout, 1u, sets, no_dyn)
set_pc(1.0f / float(HDR_W), 1.0f / float(HDR_H), threshold, soft_knee, bloom_intensity)
hdr_bright_fs_push_constants(cmd, res.post_pipe_layout)
cmd_draw(cmd, 3u, 1u)
}
delete b0_clears
// ---- downsample chain : bloom[i] -> bloom[i+1] ----
for (i in range(BLOOM_LEVELS - 1)) {
var clears <- [clear_color(0.0f, 0.0f, 0.0f, 1.0f)]
record_render_pass(cmd, res.rp_post_clear, res.fb_post_clear[i + 1],
full_area(BLOOM_W[i + 1], BLOOM_H[i + 1]), clears) {
set_dyn_vp(cmd, BLOOM_W[i + 1], BLOOM_H[i + 1])
cmd_bind_pipeline(cmd, res.down_pipeline)
let sets <- [vk_value_to_boost(res.down_set[i])]
var no_dyn : array<uint>
cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.post_pipe_layout, 1u, sets, no_dyn)
set_pc(1.0f / float(BLOOM_W[i]), 1.0f / float(BLOOM_H[i]),
threshold, soft_knee, bloom_intensity)
hdr_down_fs_push_constants(cmd, res.post_pipe_layout)
cmd_draw(cmd, 3u, 1u)
}
delete clears
}
// ---- upsample chain (additive blend) : bloom[i+1] -> bloom[i] ----
for (k in range(BLOOM_LEVELS - 1)) {
let i = BLOOM_LEVELS - 2 - k // iterate dst = 3, 2, 1, 0
// record_render_pass requires N clears for N attachments even when loadOp=LOAD ignores
// them; pass a single dummy clear so the pClearValues pointer is valid.
var up_clears <- [clear_color(0.0f, 0.0f, 0.0f, 1.0f)]
record_render_pass(cmd, res.rp_post_load, res.fb_post_load[i],
full_area(BLOOM_W[i], BLOOM_H[i]), up_clears) {
set_dyn_vp(cmd, BLOOM_W[i], BLOOM_H[i])
cmd_bind_pipeline(cmd, res.up_pipeline)
let sets <- [vk_value_to_boost(res.up_set[i])]
var no_dyn : array<uint>
cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.post_pipe_layout, 1u, sets, no_dyn)
set_pc(1.0f / float(BLOOM_W[i + 1]), 1.0f / float(BLOOM_H[i + 1]),
threshold, soft_knee, bloom_intensity)
hdr_up_fs_push_constants(cmd, res.post_pipe_layout)
cmd_draw(cmd, 3u, 1u)
}
delete up_clears
}
// ---- composite pass : HDR + bloom[0] -> ACES -> LDR ----
var comp_clears <- [clear_color(0.0f, 0.0f, 0.0f, 1.0f)]
record_render_pass(cmd, res.rp_composite, res.fb_composite, full_area(HDR_W, HDR_H), comp_clears) {
cmd_bind_pipeline(cmd, res.composite_pipeline)
let sets <- [vk_value_to_boost(res.composite_set)]
var no_dyn : array<uint>
cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.composite_pipe_layout, 1u, sets, no_dyn)
set_pc(1.0f / float(HDR_W), 1.0f / float(HDR_H), threshold, soft_knee, bloom_intensity)
hdr_composite_fs_push_constants(cmd, res.composite_pipe_layout)
cmd_draw(cmd, 3u, 1u)
}
delete comp_clears
}
Self-verifying
The pixel oracle is the CI regression gate (lavapipe in CI, real GPU locally). It renders one frame and asserts: (1) the four corners are still background colour (no global lift – bloom doesn’t smear into the dark margins outside a ~50-texel radius); (2) the central band contains at least one near-saturated emissive cube (the HDR + ACES pipeline made it through without an early clamp); (3) the luminance histogram has a heavy “bright” tail, which a no-bloom render of the same scene wouldn’t produce.
[test]
def test_hdr_oracle(t : T?) {
var pixels <- render_hdr_scene(TEST_TIME, TEST_CAM)
// (1) corners are background
t |> success(bg(px(pixels, 8, 8)), "top-left corner is background")
t |> success(bg(px(pixels, HDR_W - 8, 8)), "top-right corner is background")
t |> success(bg(px(pixels, 8, HDR_H - 8)), "bottom-left corner is background")
t |> success(bg(px(pixels, HDR_W - 8, HDR_H - 8)), "bottom-right corner is background")
// (2) at least one near-saturated emissive cube somewhere in the central band
var max_l = 0
var n_saturated = 0
let cx = HDR_W / 2
let cy = HDR_H / 2
for (j in range(160)) {
for (i in range(160)) {
let y = cy - 240 + j * 3
let x = cx - 240 + i * 3
let p = px(pixels, x, y)
let l = luma(p)
if (l > max_l) {
max_l = l
}
if (max(max(p.x, p.y), p.z) >= 240) {
n_saturated++
}
}
}
t |> success(max_l >= 200, "brightest sampled pixel is very bright ({max_l} >= 200, emissive cube + tonemap)")
t |> success(n_saturated >= 4, "at least 4 near-saturated samples (emissive cubes survived ACES)")
// (3) bloom halo histogram: 1024 sample positions across the central 480x480 region, counting
// "bright" pixels (luma 80..200) -- the halo around emissive cubes. Without bloom that count comes
// from lit non-emissive cubes alone; bloom's halo dilates it above scene-only lighting.
var n_bright = 0
var n_total = 0
for (j in range(32)) {
for (i in range(32)) {
let y = cy - 240 + j * 15
let x = cx - 240 + i * 15
let p = px(pixels, x, y)
let l = luma(p)
if (l >= 80 && l < 200) {
n_bright++
}
n_total++
}
}
let pct = (n_bright * 100) / max(n_total, 1)
t |> success(n_bright >= 50, "at least 50 bright/halo samples ({n_bright}/{n_total} ~= {pct}% in central band)")
delete pixels
}
See it live
window/show_hdr.das opens a GLFW window with a Vulkan swapchain and
runs the HDR + bloom pipeline per frame with time derived from
wall-clock. The camera orbits the swarm at the same speed as the
recording. No mode toggle – the visual story is the bloom, not a
debug overlay.
require glfw/glfw_boost
require vulkan
require vulkan/vulkan_boost
require vulkan/vulkan_window
require ../hdr_tut.das
require daslib/defer
require math
//! Tutorial 11 writes linear 16-bit-float to the composite target and relies on the swap surface
//! being sRGB so the hardware does the linear->sRGB encoding at the blit-format step. If the
//! platform's swapchain falls back to a non-sRGB format the presented image would be visibly too
//! dark (no gamma applied). Fail loud rather than silently render wrong. Called both at initial
//! creation and after every swapchain recreation (resize / out-of-date), since recreation can
//! select a different surface format.
def require_srgb_swap(swap : Swapchain) {
if (swap.format != VkFormat.B8G8R8A8_SRGB && swap.format != VkFormat.R8G8B8A8_SRGB) {
panic("tutorial 11 requires an sRGB swap-target format so the linear->sRGB encoding happens at the blit; got non-sRGB format -- consider extending the tutorial with a runtime gamma-encode path for that platform")
}
}
[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(HDR_W, HDR_H, "dasVulkan tutorial 11 - HDR + bloom (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 11 (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_hdr_resources(device, phys, queue, pool)
var inscope swap <- create_swapchain(device, phys, surface, HDR_W, HDR_H)
require_srgb_swap(swap)
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
require_srgb_swap(swap) // recreation can fall back to a different surface format
}
let t = float(glfwGetTime())
// 30-second camera orbit -- one full circle per 30s of wall clock; matches the recording
// driver (900 frames at 30 fps = 30 s per orbit), so frame N in the window corresponds to
// the same camera angle as frame N in the recording.
let raw_cam = t / 30.0f
let camera_t = raw_cam - floor(raw_cam)
update_hdr_uniforms(res, device, t, camera_t)
let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
record_hdr_frame(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 = HDR_W
region.srcOffsets[1].y = HDR_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.ldr_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/11_hdr
# watch it live in a window (needs the glfw module + a display)
daslang -load_module <dasVulkan> \
<dasVulkan>/tutorials/11_hdr/window/show_hdr.das
# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
<dasVulkan>/tutorials/11_hdr/recording/record_hdr.das
12 - GPU-driven: Hi-Z occlusion culling + indirect-count + bindless hands the draw decision to the GPU: a compute
shader runs Hi-Z occlusion culling against a depth pyramid, compacts
the survivors into an indirect-draw buffer with a GPU-written count, and
cmd_draw_indexed_indirect_count draws exactly that many with
bindless materials – the CPU never learns how many objects survive.
A side-by-side god view tints the culled cubes as ghosts so the
otherwise-invisible cull becomes visible.