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 :doc:`05_instancing`, 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. Hard ``step()`` 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 uses ``loadOp = LOAD`` so 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. .. video:: vulkan_hdr.mp4 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). .. literalinclude:: ../../../../../modules/dasVulkan/tutorials/11_hdr/hdr_tut_shaders.das :language: das :start-at: module hdr_tut_shaders 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. .. literalinclude:: ../../../../../modules/dasVulkan/tutorials/11_hdr/hdr_tut.das :language: das :start-at: def public record_hdr_frame :end-before: //! Per-frame: 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. .. literalinclude:: ../../../../../modules/dasVulkan/tutorials/11_hdr/test_hdr.das :language: das :start-at: [test] 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. .. literalinclude:: ../../../../../modules/dasVulkan/tutorials/11_hdr/window/show_hdr.das :language: das :start-at: require glfw/glfw_boost Running it ---------- .. code-block:: bash # the CI pixel-oracle gate (lavapipe in CI, real GPU locally) daslang.exe dastest/dastest.das -- --test modules/dasVulkan/tutorials/11_hdr # watch it live in a window (needs the glfw module + a display) daslang.exe modules/dasVulkan/tutorials/11_hdr/window/show_hdr.das # regenerate the recording (needs stbimage + audio + ffmpeg locally) daslang.exe modules/dasVulkan/tutorials/11_hdr/recording/record_hdr.das :doc:`12_gpu_driven` 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.