concinnity-device 0.18.68

GPU backends (Metal, Vulkan, DirectX) behind a device facade for Concinnity
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
// src/vulkan/post/ssao.rs
//
// SSAO (GTAO) for the Vulkan backend. Owns the GTAO horizon-search kernel
// pipeline, the depth-aware blur pipeline, and the `encode_ssao` per-frame
// encoder. The depth + normal the kernel / blur sample come from the unified
// G-buffer pre-pass; the kernel / blur sets are wired to its per-frame views
// by `init.rs`.
//
// The main pass samples `SsaoResources::ao` (the blurred occlusion) at set 0
// binding 6 to modulate its ambient term; when SSAO is disabled the renderer
// binds the 1×1 `ssao_white` fallback at that slot so the multiplier is a
// pass-through 1.0. Mirrors src/directx/post/ssao.rs.

use ash::vk;

use crate::vulkan::owned::{
    OwnedDescriptorPool, OwnedFramebuffer, OwnedPipeline, OwnedPipelineLayout, OwnedRenderPass,
    OwnedSampler, OwnedSetLayout, VkDevice,
};
use crate::vulkan::record::Recorder;

use crate::gfx::render_types::SsaoParams;

use super::super::allocator::DeviceAllocator;
use super::super::context::VkContext;
use super::super::pipeline::*;
use super::super::resources::{alloc_descriptor_sets, create_descriptor_set_layout};
use super::super::texture::*;
use crate::vulkan::slang_builtins::SlangCompile;

// Single-channel occlusion target format. 1.0 = unoccluded; the main pass
// multiplies the ambient term by this value.
pub(in crate::vulkan) const SSAO_OCCLUSION_FORMAT: vk::Format = vk::Format::R8_UNORM;

// SSAO resources held by `VkContext` when `PostProcessConfig.ssao` is on.
// All `vk::*` handles are owned by this struct and freed on `destroy`.
pub(in crate::vulkan) struct SsaoResources {
    // Resolved authored tunables; turned into a per-frame `SsaoParams` push.
    pub(in crate::vulkan) settings: crate::gfx::ssao::SsaoSettings,

    // The kernel pass writes `ao_raw` through this render pass: it discards
    // on load (UNDEFINED) and stores SHADER_READ_ONLY so the blur can sample
    // ao_raw. `ao_raw` is SSAO-internal (not a graph resource), so it stays
    // render-pass-driven.
    pub(in crate::vulkan) fullscreen_render_pass: OwnedRenderPass,

    // The blur pass writes `ao` (the graph's `ao_output`) through this render
    // pass. `ao`'s layout transitions are graph-driven, so this pass performs
    // none: it keeps `ao` in COLOR_ATTACHMENT_OPTIMAL (initial == final) and
    // the executor emits ao_output's `barriers_before` around it
    // (UNDEFINED -> COLOR_ATTACHMENT before SsaoBlur, COLOR_ATTACHMENT ->
    // SHADER_READ before Main).
    pub(in crate::vulkan) blur_render_pass: OwnedRenderPass,

    // Kernel pipeline (GTAO horizon search): fullscreen triangle reading
    // the G-buffer, writing the raw R8 occlusion target.
    pub(in crate::vulkan) _kernel_set_layout: OwnedSetLayout,
    pub(in crate::vulkan) kernel_layout: OwnedPipelineLayout,
    pub(in crate::vulkan) kernel_pso: OwnedPipeline,

    // Blur pipeline: fullscreen triangle reading raw occlusion + G-buffer
    // depth, writing the final blurred occlusion target.
    pub(in crate::vulkan) _blur_set_layout: OwnedSetLayout,
    pub(in crate::vulkan) blur_layout: OwnedPipelineLayout,
    pub(in crate::vulkan) blur_pso: OwnedPipeline,

    // Per-frame kernel / blur sets. The kernel set binding 0 and the blur set
    // binding 1 sample the unified pre-pass G-buffer normal+depth (a per-frame
    // target), so these sets are per-frame too (one slot per frame in flight).
    // The blur set binding 0 samples the SSAO-internal raw AO, a single shared
    // target.
    pub(in crate::vulkan) kernel_sets: Vec<vk::DescriptorSet>,
    pub(in crate::vulkan) blur_sets: Vec<vk::DescriptorSet>,
    pub(in crate::vulkan) _descriptor_pool: OwnedDescriptorPool,

    // Linear-clamp sampler for the kernel/blur G-buffer / raw-AO reads.
    pub(in crate::vulkan) sampler: OwnedSampler,

    // Resolution-dependent targets (rebuilt on swapchain resize). `ao_raw` is
    // SSAO-internal (the kernel's raw occlusion, sampled by the blur). The
    // blurred `ao_output` the main pass samples is the graph's transient and is
    // owned by `VkContext::transient_pool`, per frame in flight; there is one
    // `blur_framebuffers` entry per frame, each built from that frame's pooled
    // `ao_output` view passed in at build time.
    pub(in crate::vulkan) ao_raw: GpuImage,
    pub(in crate::vulkan) kernel_framebuffer: OwnedFramebuffer,
    pub(in crate::vulkan) blur_framebuffers: Vec<OwnedFramebuffer>,
}

// SPIR-V blobs for every SSAO pipeline. Produced by
// [`compile_ssao_shaders`]; consumed by `SsaoResources::new` at init and by
// `rebuild_ssao_pipelines` during shader hot-reload. Mirrors
// [`crate::vulkan::post::bloom::BloomShaders`].
pub(in crate::vulkan) struct SsaoShaders {
    pub fullscreen_vs: Vec<u8>,
    pub kernel_fs: Vec<u8>,
    pub blur_fs: Vec<u8>,
}

// Compile the SSAO stages from `src/shaders/ssao.slang` plus the shared
// single-source fullscreen vertex. `hot_reload` routes each source resolve
// through the disk-first path so dev-loop edits take effect on the next
// pipeline build. Called from `SsaoResources::new` at init and by the Vulkan
// shader hot-reload path.
pub(in crate::vulkan) fn compile_ssao_shaders(hot_reload: bool) -> Result<SsaoShaders, String> {
    use super::super::{builtins, slang_builtins};
    let ctx = builtins::Ctx::plain(hot_reload);
    Ok(SsaoShaders {
        fullscreen_vs: slang_builtins::FULLSCREEN_VERT.compile(&ctx)?,
        kernel_fs: slang_builtins::SSAO_KERNEL.compile(&ctx)?,
        blur_fs: slang_builtins::SSAO_BLUR.compile(&ctx)?,
    })
}

// Replacement SSAO pipelines built by the hot-reload pass. Each lines up
// 1:1 with the matching field on [`SsaoResources`]. Mirrors
// `directx::post::ssao::RebuiltSsaoPipelines`.
pub(in crate::vulkan) struct RebuiltSsaoPipelines {
    pub kernel: OwnedPipeline,
    pub blur: OwnedPipeline,
}

// Rebuild every live SSAO pipeline from disk-resident GLSL source against
// the existing layouts + render pass. Returns the freshly built handles;
// the caller is responsible for destroying the displaced pipelines only
// after this call succeeds (any compile / pipeline-create failure short-
// circuits with the previous handles untouched). Called by the Vulkan
// shader hot-reload path.
pub(in crate::vulkan) fn rebuild_ssao_pipelines(
    device: &VkDevice,
    ssao: &SsaoResources,
    hot_reload: bool,
) -> Result<RebuiltSsaoPipelines, String> {
    let shaders = compile_ssao_shaders(hot_reload)?;
    let kernel = create_fullscreen_pipeline(
        device,
        ssao.fullscreen_render_pass.handle(),
        ssao.kernel_layout.handle(),
        &shaders.fullscreen_vs,
        &shaders.kernel_fs,
    )?;
    let blur = create_fullscreen_pipeline(
        device,
        ssao.blur_render_pass.handle(),
        ssao.blur_layout.handle(),
        &shaders.fullscreen_vs,
        &shaders.blur_fs,
    )?;
    Ok(RebuiltSsaoPipelines { kernel, blur })
}

impl SsaoResources {
    // Swap the freshly-built pipelines into the live resources. The caller
    // has already `device_wait_idle`'d so the old pipelines are not in
    // flight. Driven by the Vulkan shader hot-reload pass after every
    // replacement successfully compiled.
    pub(in crate::vulkan) fn swap_pipelines(&mut self, rebuilt: RebuiltSsaoPipelines) {
        self.kernel_pso = rebuilt.kernel;
        self.blur_pso = rebuilt.blur;
    }
}

// Kernel render pass: one R8_UNORM colour attachment, no depth. The
// fullscreen triangle overwrites every pixel so `DONT_CARE` is safe on load.
// Ends shader-readable so the blur can sample the raw occlusion it writes.
fn create_fullscreen_render_pass(device: &VkDevice) -> Result<OwnedRenderPass, String> {
    let attachment = vk::AttachmentDescription::default()
        .format(SSAO_OCCLUSION_FORMAT)
        .samples(vk::SampleCountFlags::TYPE_1)
        .load_op(vk::AttachmentLoadOp::DONT_CARE)
        .store_op(vk::AttachmentStoreOp::STORE)
        .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
        .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
        .initial_layout(vk::ImageLayout::UNDEFINED)
        .final_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL);
    let color_ref = vk::AttachmentReference::default()
        .attachment(0)
        .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
    let subpass = vk::SubpassDescription::default()
        .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
        .color_attachments(std::slice::from_ref(&color_ref));
    let dep = vk::SubpassDependency::default()
        .src_subpass(vk::SUBPASS_EXTERNAL)
        .dst_subpass(0)
        .src_stage_mask(
            vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
                | vk::PipelineStageFlags::FRAGMENT_SHADER,
        )
        .src_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
        .dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
        .dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE);
    let info = vk::RenderPassCreateInfo::default()
        .attachments(std::slice::from_ref(&attachment))
        .subpasses(std::slice::from_ref(&subpass))
        .dependencies(std::slice::from_ref(&dep));
    device
        .create_render_pass(&info)
        .map_err(|e| format!("SSAO fullscreen render pass: {e}"))
}

// Blur render pass: same R8_UNORM colour attachment as the kernel pass, but
// it performs no layout transition. `ao` (the graph's `ao_output`) enters and
// leaves in COLOR_ATTACHMENT_OPTIMAL; the executor emits ao_output's
// graph-derived barriers around the SsaoBlur and Main passes. The
// SUBPASS_EXTERNAL dependency is kept identical to the kernel pass so the
// write-after-read hazard against the previous frame's main-pass sample of
// `ao` stays guarded.
fn create_blur_render_pass(device: &VkDevice) -> Result<OwnedRenderPass, String> {
    let attachment = vk::AttachmentDescription::default()
        .format(SSAO_OCCLUSION_FORMAT)
        .samples(vk::SampleCountFlags::TYPE_1)
        .load_op(vk::AttachmentLoadOp::DONT_CARE)
        .store_op(vk::AttachmentStoreOp::STORE)
        .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
        .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
        .initial_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
        .final_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
    let color_ref = vk::AttachmentReference::default()
        .attachment(0)
        .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
    let subpass = vk::SubpassDescription::default()
        .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
        .color_attachments(std::slice::from_ref(&color_ref));
    let dep = vk::SubpassDependency::default()
        .src_subpass(vk::SUBPASS_EXTERNAL)
        .dst_subpass(0)
        .src_stage_mask(
            vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
                | vk::PipelineStageFlags::FRAGMENT_SHADER,
        )
        .src_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
        .dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
        .dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE);
    let info = vk::RenderPassCreateInfo::default()
        .attachments(std::slice::from_ref(&attachment))
        .subpasses(std::slice::from_ref(&subpass))
        .dependencies(std::slice::from_ref(&dep));
    device
        .create_render_pass(&info)
        .map_err(|e| format!("SSAO blur render pass: {e}"))
}

// Allocate an R8_UNORM target usable as both colour attachment and sampled
// texture. No pre-transition: the render pass declares an `UNDEFINED`
// initial layout.
fn create_ao_target(
    alloc: &DeviceAllocator,
    device: &VkDevice,
    width: u32,
    height: u32,
) -> Result<GpuImage, String> {
    let pooled = create_image(
        alloc,
        &super::super::texture::ImageSpec {
            width,
            height,
            format: SSAO_OCCLUSION_FORMAT,
            tiling: vk::ImageTiling::OPTIMAL,
            usage: vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::SAMPLED,
            mem_props: vk::MemoryPropertyFlags::DEVICE_LOCAL,
            samples: vk::SampleCountFlags::TYPE_1,
        },
    )?;
    let view = create_image_view(
        device,
        pooled.image(),
        SSAO_OCCLUSION_FORMAT,
        vk::ImageAspectFlags::COLOR,
    )?;
    Ok(GpuImage::from_pooled(pooled, view))
}

// Build a fullscreen kernel/blur pipeline. No vertex input (the
// fullscreen triangle is procedural in the VS); no depth; no blend; writes
// the R8 occlusion target.
fn create_fullscreen_pipeline(
    device: &VkDevice,
    render_pass: vk::RenderPass,
    layout: vk::PipelineLayout,
    vert_spv: &[u8],
    frag_spv: &[u8],
) -> Result<OwnedPipeline, String> {
    let vert_mod = spv_module(device, vert_spv)?;
    let frag_mod = spv_module(device, frag_spv)?;
    let entry = std::ffi::CString::new("main").unwrap();

    let stages = [
        vk::PipelineShaderStageCreateInfo::default()
            .stage(vk::ShaderStageFlags::VERTEX)
            .module(vert_mod.handle())
            .name(&entry),
        vk::PipelineShaderStageCreateInfo::default()
            .stage(vk::ShaderStageFlags::FRAGMENT)
            .module(frag_mod.handle())
            .name(&entry),
    ];
    let vert_input = vk::PipelineVertexInputStateCreateInfo::default();
    let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
        .topology(vk::PrimitiveTopology::TRIANGLE_LIST);
    let viewport_state = vk::PipelineViewportStateCreateInfo::default()
        .viewport_count(1)
        .scissor_count(1);
    let raster = vk::PipelineRasterizationStateCreateInfo::default()
        .polygon_mode(vk::PolygonMode::FILL)
        .line_width(1.0)
        .cull_mode(vk::CullModeFlags::NONE)
        .front_face(vk::FrontFace::COUNTER_CLOCKWISE);
    let multisample = vk::PipelineMultisampleStateCreateInfo::default()
        .rasterization_samples(vk::SampleCountFlags::TYPE_1);
    let depth = vk::PipelineDepthStencilStateCreateInfo::default()
        .depth_test_enable(false)
        .depth_write_enable(false)
        .depth_compare_op(vk::CompareOp::ALWAYS);
    let blend_attach = vk::PipelineColorBlendAttachmentState::default()
        .color_write_mask(vk::ColorComponentFlags::R)
        .blend_enable(false);
    let blend = vk::PipelineColorBlendStateCreateInfo::default()
        .attachments(std::slice::from_ref(&blend_attach));
    let dyn_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
    let dynamic = vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dyn_states);

    let info = vk::GraphicsPipelineCreateInfo::default()
        .stages(&stages)
        .vertex_input_state(&vert_input)
        .input_assembly_state(&input_assembly)
        .viewport_state(&viewport_state)
        .rasterization_state(&raster)
        .multisample_state(&multisample)
        .depth_stencil_state(&depth)
        .color_blend_state(&blend)
        .dynamic_state(&dynamic)
        .layout(layout)
        .render_pass(render_pass)
        .subpass(0);
    let pipeline = crate::vulkan::pipeline_cache::create_graphics_pipeline(device, &info)
        .map_err(|e| format!("create ssao fullscreen pso: {e}"))?;
    Ok(pipeline)
}

// The Vulkan device handles every SSAO target builder threads through: the
// instance, logical device, and physical device used to allocate images. Bundled
// because they always travel together through `new` / `build_targets` / `rebuild`.
#[derive(Clone, Copy)]
pub(in crate::vulkan) struct SsaoDeviceCtx<'a> {
    pub alloc: &'a DeviceAllocator,
    pub device: &'a VkDevice,
}

impl SsaoResources {
    pub(in crate::vulkan) fn new(
        ctx: &SsaoDeviceCtx,
        width: u32,
        height: u32,
        frames: usize,
        settings: crate::gfx::ssao::SsaoSettings,
        ao_views: &[vk::ImageView],
        hot_reload: bool,
    ) -> Result<Self, String> {
        let device = ctx.device;
        let fullscreen_render_pass = create_fullscreen_render_pass(device)?;
        let blur_render_pass = create_blur_render_pass(device)?;

        // Kernel set 0: G-buffer sampler.
        let kernel_set_layout = create_descriptor_set_layout(
            device,
            &[(
                0,
                vk::DescriptorType::COMBINED_IMAGE_SAMPLER,
                vk::ShaderStageFlags::FRAGMENT,
            )],
        )?;
        // Blur set 0: ao_raw + G-buffer samplers.
        let blur_set_layout = create_descriptor_set_layout(
            device,
            &[
                (
                    0,
                    vk::DescriptorType::COMBINED_IMAGE_SAMPLER,
                    vk::ShaderStageFlags::FRAGMENT,
                ),
                (
                    1,
                    vk::DescriptorType::COMBINED_IMAGE_SAMPLER,
                    vk::ShaderStageFlags::FRAGMENT,
                ),
            ],
        )?;

        // Pipeline layouts.
        let params_push = vk::PushConstantRange::default()
            .stage_flags(vk::ShaderStageFlags::FRAGMENT)
            .offset(0)
            .size(std::mem::size_of::<SsaoParams>() as u32);
        let kernel_set_layouts = [kernel_set_layout.handle()];
        let kernel_layout = device
            .create_pipeline_layout(
                &vk::PipelineLayoutCreateInfo::default()
                    .set_layouts(&kernel_set_layouts)
                    .push_constant_ranges(std::slice::from_ref(&params_push)),
            )
            .map_err(|e| format!("ssao kernel layout: {e}"))?;

        let blur_set_layouts = [blur_set_layout.handle()];
        let blur_layout = device
            .create_pipeline_layout(
                &vk::PipelineLayoutCreateInfo::default().set_layouts(&blur_set_layouts),
            )
            .map_err(|e| format!("ssao blur layout: {e}"))?;

        // Pipelines.
        let shaders = compile_ssao_shaders(hot_reload)?;
        let kernel_pso = create_fullscreen_pipeline(
            device,
            fullscreen_render_pass.handle(),
            kernel_layout.handle(),
            &shaders.fullscreen_vs,
            &shaders.kernel_fs,
        )?;
        let blur_pso = create_fullscreen_pipeline(
            device,
            blur_render_pass.handle(),
            blur_layout.handle(),
            &shaders.fullscreen_vs,
            &shaders.blur_fs,
        )?;

        // Descriptor pool: `frames` kernel sets (1 sampler each) + `frames` blur
        // sets (2 samplers each). The kernel/blur sets are per-frame so each
        // binds its own frame's unified G-buffer normal+depth.
        let pool_sizes = [vk::DescriptorPoolSize::default()
            .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
            .descriptor_count(frames as u32 * 3)];
        let descriptor_pool = device
            .create_descriptor_pool(
                &vk::DescriptorPoolCreateInfo::default()
                    .pool_sizes(&pool_sizes)
                    .max_sets(frames as u32 * 2),
            )
            .map_err(|e| format!("ssao descriptor pool: {e}"))?;

        let kernel_layouts: Vec<_> = (0..frames).map(|_| kernel_set_layout.handle()).collect();
        let kernel_sets = alloc_descriptor_sets(device, descriptor_pool.handle(), &kernel_layouts)?;
        let blur_layouts: Vec<_> = (0..frames).map(|_| blur_set_layout.handle()).collect();
        let blur_sets = alloc_descriptor_sets(device, descriptor_pool.handle(), &blur_layouts)?;

        // Dedicated linear-clamp sampler for kernel/blur reads.
        let sampler = create_sampler_linear_clamp(device)?;

        // Resolution-dependent targets + framebuffers.
        let mut me = Self {
            settings,
            fullscreen_render_pass,
            blur_render_pass,
            _kernel_set_layout: kernel_set_layout,
            kernel_layout,
            kernel_pso,
            _blur_set_layout: blur_set_layout,
            blur_layout,
            blur_pso,
            kernel_sets,
            blur_sets,
            _descriptor_pool: descriptor_pool,
            sampler,
            // Placeholder GpuImage; replaced by build_targets below. The
            // blurred `ao_output` lives in the transient pool, not here.
            ao_raw: GpuImage::null(),
            kernel_framebuffer: OwnedFramebuffer::null(),
            blur_framebuffers: Vec::new(),
        };
        me.build_targets(ctx, width, height, ao_views)?;
        // The kernel/blur normal+depth bindings are re-pointed at the unified
        // pre-pass per-frame views by the caller before the first frame; the
        // raw-AO view stands in until then so every binding is valid.
        me.wire_kernel_and_blur_sets(device, &[]);
        Ok(me)
    }

    // Allocate or re-allocate the resolution-dependent targets + framebuffers
    // at the given extent. Caller has either just constructed `self` (raw
    // fields are `NULL`) or already idled the device + destroyed the previous
    // targets via `destroy_targets`.
    fn build_targets(
        &mut self,
        ctx: &SsaoDeviceCtx,
        width: u32,
        height: u32,
        ao_views: &[vk::ImageView],
    ) -> Result<(), String> {
        let SsaoDeviceCtx { alloc, device } = ctx;
        let w = width.max(1);
        let h = height.max(1);
        self.ao_raw = create_ao_target(alloc, device, w, h)?;

        self.kernel_framebuffer = device
            .create_framebuffer(
                &vk::FramebufferCreateInfo::default()
                    .render_pass(self.fullscreen_render_pass.handle())
                    .attachments(std::slice::from_ref(&self.ao_raw.view))
                    .width(w)
                    .height(h)
                    .layers(1),
            )
            .map_err(|e| format!("ssao kernel framebuffer: {e}"))?;
        // One blur framebuffer per frame in flight, each bound to that frame's
        // pooled `ao_output` view.
        let mut blur_framebuffers = Vec::with_capacity(ao_views.len());
        for &ao_view in ao_views {
            let fb = device
                .create_framebuffer(
                    &vk::FramebufferCreateInfo::default()
                        .render_pass(self.blur_render_pass.handle())
                        .attachments(std::slice::from_ref(&ao_view))
                        .width(w)
                        .height(h)
                        .layers(1),
                )
                .map_err(|e| format!("ssao blur framebuffer: {e}"))?;
            blur_framebuffers.push(fb);
        }
        self.blur_framebuffers = blur_framebuffers;
        Ok(())
    }

    // Wire the per-frame kernel + blur descriptor sets to the current G-buffer /
    // raw-AO views. Called after `build_targets` (init or resize) so the
    // descriptor-set targets stay in sync with the underlying images.
    //
    // `gbuffer_views` carries the unified pre-pass's per-frame normal+depth
    // views; kernel/blur set `i` binds slot `i`. When empty (the init pre-wire
    // before the caller re-points them) the raw-AO view stands in so the binding
    // is always a valid `SHADER_READ_ONLY` image. The blur set binding 0 always
    // samples the SSAO-internal raw AO (a single shared target).
    fn wire_kernel_and_blur_sets(&self, device: &VkDevice, gbuffer_views: &[vk::ImageView]) {
        let raw_info = vk::DescriptorImageInfo::default()
            .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
            .image_view(self.ao_raw.view)
            .sampler(self.sampler.handle());
        for f in 0..self.kernel_sets.len() {
            let gb_view = if gbuffer_views.is_empty() {
                self.ao_raw.view
            } else {
                gbuffer_views[f % gbuffer_views.len()]
            };
            let gb_info = vk::DescriptorImageInfo::default()
                .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
                .image_view(gb_view)
                .sampler(self.sampler.handle());
            let writes = [
                vk::WriteDescriptorSet::default()
                    .dst_set(self.kernel_sets[f])
                    .dst_binding(0)
                    .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
                    .image_info(std::slice::from_ref(&gb_info)),
                vk::WriteDescriptorSet::default()
                    .dst_set(self.blur_sets[f])
                    .dst_binding(0)
                    .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
                    .image_info(std::slice::from_ref(&raw_info)),
                vk::WriteDescriptorSet::default()
                    .dst_set(self.blur_sets[f])
                    .dst_binding(1)
                    .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
                    .image_info(std::slice::from_ref(&gb_info)),
            ];
            // SAFETY: `writes` and the buffer/image infos it borrows are live for the call, and
            // every set and resource it names belongs to this device.
            unsafe { device.update_descriptor_sets(&writes, &[]) };
        }
    }

    // Re-point the per-frame kernel/blur G-buffer bindings at the unified
    // pre-pass per-frame normal+depth views. Called by the caller (init /
    // resize) when the unified G-buffer pre-pass is active. Mirrors the SSR
    // resolve / SSGI re-points.
    pub(in crate::vulkan) fn wire_kernel_and_blur_sets_gbuffer(
        &self,
        device: &VkDevice,
        gbuffer_views: &[vk::ImageView],
    ) {
        self.wire_kernel_and_blur_sets(device, gbuffer_views);
    }

    fn destroy_targets(&mut self, _device: &VkDevice) {
        if !self.kernel_framebuffer.is_null() {
            self.kernel_framebuffer = OwnedFramebuffer::null();
            self.blur_framebuffers.clear();
            self.ao_raw = GpuImage::null();
            // `ao_output` is pool-owned (per frame); the pool frees it.
        }
    }

    // Rebuild the resolution-dependent targets at a new swapchain extent and
    // re-wire the kernel + blur descriptor sets. The caller has already
    // idled the device.
    pub(in crate::vulkan) fn rebuild(
        &mut self,
        ctx: &SsaoDeviceCtx,
        width: u32,
        height: u32,
        gbuffer_views: &[vk::ImageView],
        ao_views: &[vk::ImageView],
    ) -> Result<(), String> {
        let device = ctx.device;
        self.destroy_targets(device);
        self.build_targets(ctx, width, height, ao_views)?;
        self.wire_kernel_and_blur_sets(device, gbuffer_views);
        Ok(())
    }

    // Destroy every SSAO resource. The caller has already idled the device.
    pub(in crate::vulkan) fn destroy(&mut self, device: &VkDevice) {
        self.destroy_targets(device);
    }
}

// Encoder
impl VkContext {
    // Encode the GTAO horizon-search kernel and the depth-aware blur over the
    // unified pre-pass's normal+depth G-buffer. Called from `record_frame`
    // before `cmd_begin_render_pass(main_render_pass)` so the main fragment
    // shader sees the fresh blurred occlusion via set 0 binding 6. No-op when
    // SSAO is disabled.
    pub(in crate::vulkan) fn encode_ssao(
        &self,
        rec: &Recorder<'_>,
        frame_idx: usize,
        fov_y_radians: f32,
        aspect: f32,
    ) {
        let Some(ssao) = &self.ssao else {
            return;
        };
        let extent = self.render_extent;
        let params = ssao.settings.params(fov_y_radians, aspect);
        let area = vk::Rect2D::default().extent(extent);

        // Kernel: GTAO horizon search over the G-buffer -> raw R8 AO. The
        // fullscreen kernel uses a positive-height viewport; the kernel shader's
        // UV map ((pos+1)/2) lines up with the upright G-buffer.
        rec.begin_render_pass(
            &ssao.fullscreen_render_pass,
            &ssao.kernel_framebuffer,
            area,
            &[],
        );
        rec.set_full_viewport(extent);
        rec.bind_pipeline(vk::PipelineBindPoint::GRAPHICS, &ssao.kernel_pso);
        rec.bind_descriptor_sets(
            vk::PipelineBindPoint::GRAPHICS,
            &ssao.kernel_layout,
            0,
            std::slice::from_ref(&ssao.kernel_sets[frame_idx]),
            &[],
        );
        rec.push_constants(
            &ssao.kernel_layout,
            vk::ShaderStageFlags::FRAGMENT,
            0,
            &params,
        );
        rec.draw_fullscreen_triangle();
        rec.end_render_pass();

        // Blur: depth-aware smoothing of raw AO -> final blurred AO. `ao`'s
        // layout transitions are graph-driven: the executor emits ao_output's
        // barriers_before (UNDEFINED -> COLOR_ATTACHMENT before this pass,
        // COLOR_ATTACHMENT -> SHADER_READ before Main), and this render pass
        // keeps `ao` in COLOR_ATTACHMENT_OPTIMAL throughout.
        rec.begin_render_pass(
            &ssao.blur_render_pass,
            &ssao.blur_framebuffers[frame_idx],
            area,
            &[],
        );
        rec.set_full_viewport(extent);
        rec.bind_pipeline(vk::PipelineBindPoint::GRAPHICS, &ssao.blur_pso);
        rec.bind_descriptor_sets(
            vk::PipelineBindPoint::GRAPHICS,
            &ssao.blur_layout,
            0,
            std::slice::from_ref(&ssao.blur_sets[frame_idx]),
            &[],
        );
        rec.draw_fullscreen_triangle();
        rec.end_render_pass();
    }
}