concinnity-device 0.19.119

GPU backends (Metal, Vulkan, DirectX) behind a device facade for Concinnity
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
//! Post and world effects: the temporal upscaler, the screen-space passes
//! sharing the unified G-buffer, TAA and the scene-input rewiring, and the
//! decal, fog, raymarch, planar reflection, transparent and auto-exposure
//! resources.

use ash::vk;
use concinnity_core::gfx::auto_exposure;
use concinnity_core::render::backend_init::{PlanarBudget, PostSettings, WorldFx};
use concinnity_core::render::decal;
use concinnity_core::render::error::RenderResult;
use concinnity_core::render::lights;
use concinnity_core::render::planar_reflection::PlanarReflectors;

use super::ray_tracing::RtResources;
use super::{Features, InitGpu};
use crate::vulkan::context::{
    AutoExposureState, CompositeState, DecalState, FogState, HDR_FORMAT, VkCull, VkDescriptors,
    VkGeometry, VkSceneAssets, VkTargets,
};
use crate::vulkan::global_set::GlobalBindings;
use crate::vulkan::planar::PlanarReflectionSet;
use crate::vulkan::post::PostSupport;
use crate::vulkan::post::bloom::composite_bloom_view;
use crate::vulkan::post::gbuffer::GbufferResources;
use crate::vulkan::post::post_device::{PostQueue, VkPostDevice, VkPostProbes};
use crate::vulkan::post::ssao::SsaoResources;
use crate::vulkan::post::ssgi::SsgiResources;
use crate::vulkan::post::ssr::SsrResources;
use crate::vulkan::post::taa::TaaResources;
use crate::vulkan::post::upscale::VkUpscaleBackend;
use crate::vulkan::raymarch::RaymarchResources;
use crate::vulkan::swapchain::{
    composite_channels, write_composite_channel_set, write_composite_set,
};
use crate::vulkan::transparent::TransparentResources;

// Temporal upscaling (FSR / DLSS / XeSS). Built before the off-screen
// attachments, because its render dims drive the returned render extent:
// when an upscaler builds, the whole scene pipeline renders at
// `round(swapchain_extent * upscale_scale)` and the upscaler
// reconstructs the swapchain resolution. When `temporal_upscaling` is
// off (or no backend is available) the render extent is `swapchain_extent`
// and the pipeline collapses to native-resolution rendering. Bloom /
// composite / swapchain always stay at `swapchain_extent`.
// `build_upscaler` resolves `upscale_backend` against availability with
// a DLSS -> XeSS -> FSR -> native fallback (the DLSS / XeSS device
// extensions were enabled at device creation via `upscale_sdk`).
pub(super) fn build_upscale(
    gpu: &InitGpu<'_>,
    swapchain_extent: vk::Extent2D,
    post: &PostSettings,
    requested: &mut crate::upscale_sdk::UpscaleRequest,
) -> RenderResult<(Option<Box<dyn VkUpscaleBackend>>, vk::Extent2D)> {
    let InitGpu {
        hw, command_pool, ..
    } = *gpu;
    let device = &hw.device;
    let upscale = if post.temporal_upscaling {
        let (built, resolved) = crate::vulkan::post::build_upscaler(
            crate::vulkan::post::upscale::UpscalerGpu {
                alloc: &hw.alloc,
                instance: &hw.instance,
                device,
                physical_device: hw.physical_device,
                command_pool,
                queue: hw.graphics_queue,
            },
            (swapchain_extent.width, swapchain_extent.height),
            post.upscale_scale,
            requested,
        )?;
        // Arm the messenger's benign-error budget for DLSS (see
        // `DLSS_FIRST_FRAME_LAYOUT_SUPPRESS`); a no-op for other backends.
        if resolved == crate::vulkan::post::ResolvedBackend::Dlss
            && let Some(f) = device.debug_filter()
        {
            f.store(
                super::bootstrap::DLSS_FIRST_FRAME_LAYOUT_SUPPRESS,
                std::sync::atomic::Ordering::Relaxed,
            );
        }
        built
    } else {
        None
    };
    let render_extent = match &upscale {
        Some(u) => {
            let (w, h) = u.extent().render;
            vk::Extent2D {
                width: w,
                height: h,
            }
        }
        None => swapchain_extent,
    };
    Ok((upscale, render_extent))
}

// The device every shared post pass builds its pipelines and targets
// through at init. Nothing encodes before the context exists, so it
// carries the global set's layout but none of its per-frame sets.
pub(super) fn shared_post_device<'a>(
    gpu: &InitGpu<'a>,
    post_support: &'a PostSupport,
    scene: &'a VkSceneAssets,
    descriptors: &'a VkDescriptors,
) -> VkPostDevice<'a> {
    let InitGpu {
        hw,
        command_pool,
        hot_reload,
        ..
    } = *gpu;
    VkPostDevice {
        device: &hw.device,
        alloc: &hw.alloc,
        queue: PostQueue {
            command_pool,
            queue: hw.graphics_queue,
        },
        cache: &post_support.cache,
        arena: &post_support.arena,
        sampler: post_support.sampler.handle(),
        cube_sampler: scene.cube_sampler.handle(),
        probes: VkPostProbes {
            layout: descriptors.global_set_layout.handle(),
            sets: &[],
        },
        frame: 0,
        hot_reload,
    }
}

pub(super) struct ScreenSpaceInputs<'a> {
    pub(super) post: &'a PostSettings,
    pub(super) features: &'a Features,
    pub(super) targets: &'a VkTargets,
    pub(super) scene: &'a VkSceneAssets,
    pub(super) descriptors: &'a VkDescriptors,
}

pub(super) struct ScreenSpace {
    pub(super) ssao: Option<SsaoResources>,
    pub(super) post: PostSupport,
    pub(super) ssr: Option<SsrResources>,
    pub(super) gbuffer: Option<GbufferResources>,
    pub(super) ssgi: Option<SsgiResources>,
}

pub(super) fn build_screen_space(
    gpu: &InitGpu<'_>,
    inputs: ScreenSpaceInputs<'_>,
) -> RenderResult<ScreenSpace> {
    let InitGpu {
        hw,
        command_pool,
        frames,
        hot_reload,
        ..
    } = *gpu;
    let (device, alloc) = (&hw.device, &hw.alloc);
    let ScreenSpaceInputs {
        post,
        features,
        targets,
        scene,
        descriptors,
    } = inputs;
    let render_extent = targets.render_extent;
    let post_support = crate::vulkan::post::PostSupport::new(device, frames)?;
    let init_post_device = shared_post_device(gpu, &post_support, scene, descriptors);

    // SSAO (GTAO): the shared depth copy, kernel + blur. Its blurred output is
    // the transient pool's per-frame `ao_output`, read per frame.
    let ssao = match post.ssao {
        Some(settings) => Some(SsaoResources::new(
            &init_post_device,
            settings,
            render_extent,
        )?),
        None => None,
    };

    // SSR (screen-space reflections): depth + normal + roughness pre-pass
    // and a fullscreen ray-march resolve. The pre-pass G-buffer is shared
    // with SSGI and RT, so `SsrResources` is built whenever any of them is
    // on; its settings stay `None` unless SSR itself is authored, and the
    // resolve runs only when it is and RT did not take its graph slot.
    let ssr = if post.ssr.is_some() || post.ssgi.is_some() || features.rt_wanted {
        Some(crate::vulkan::post::ssr::SsrResources::new(
            &init_post_device,
            post.ssr,
            render_extent,
        )?)
    } else {
        None
    };

    // Unified geometry G-buffer pre-pass. Built whenever any screen-space
    // consumer of the merged buffer is on: SSR resolve / SSGI / RT (all
    // fold into `ssr`), SSAO, or the velocity channel a TAA / upscale
    // consumer needs. One jittered traversal rasterizes the normal+depth /
    // roughness / velocity MRT every reader then samples, replacing the
    // separate SSR / SSAO / velocity pre-passes. The skinned variant is built
    // lazily by `upload_skinned` once the joint-set layout exists (it doesn't
    // at init). Mirrors the DirectX `self.gbuffer` build. The gate is the
    // same `gbuffer_enabled` the transient pool was built from, so the pool
    // cannot place the MRT channels for a pre-pass that is not built
    // (harmless) or -- the dangerous direction -- leave them unplaced for one
    // that is.
    let gbuffer = if features.gbuffer_enabled {
        Some(crate::vulkan::post::gbuffer::GbufferResources::new(
            crate::vulkan::post::gbuffer::GbufferDeviceCtx { alloc, device },
            crate::vulkan::post::gbuffer::GbufferQueueCtx {
                command_pool,
                queue: hw.graphics_queue,
            },
            crate::vulkan::post::gbuffer::GbufferExtent {
                width: render_extent.width,
                height: render_extent.height,
                frames,
            },
            &targets.transient_pool.gbuffer_pooled(frames),
            hot_reload,
        )?)
    } else {
        None
    };

    // SSGI (screen-space global illumination): the Hi-Z traced, temporally
    // accumulated GI pass. Built only when the world selected
    // `indirect_lighting: ssgi`; it samples the unified pre-pass G-buffer,
    // which SSGI forces on, and each frame's HDR resolve.
    let ssgi = match post.ssgi {
        Some(settings) => Some(crate::vulkan::post::ssgi::SsgiResources::new(
            &init_post_device,
            settings,
            render_extent,
        )?),
        None => None,
    };
    Ok(ScreenSpace {
        ssao,
        post: post_support,
        ssr,
        gbuffer,
        ssgi,
    })
}

pub(super) struct SceneInputWiring<'a> {
    pub(super) features: &'a Features,
    pub(super) targets: &'a VkTargets,
    pub(super) scene: &'a VkSceneAssets,
    pub(super) descriptors: &'a VkDescriptors,
    pub(super) composite: &'a CompositeState,
    pub(super) screen: &'a ScreenSpace,
    pub(super) upscale: Option<&'a dyn VkUpscaleBackend>,
}

pub(super) fn build_taa_and_wire_scene_inputs(
    gpu: &InitGpu<'_>,
    inputs: SceneInputWiring<'_>,
) -> RenderResult<Option<TaaResources>> {
    let InitGpu { hw, frames, .. } = *gpu;
    let device = &hw.device;
    let SceneInputWiring {
        features,
        targets,
        scene,
        descriptors,
        composite,
        screen,
        upscale,
    } = inputs;
    let init_post_device = shared_post_device(gpu, &screen.post, scene, descriptors);
    // When TAA is on the history resolve produces a post-TAA scene image; the
    // composite pass must sample that instead of the raw HDR resolve, so its
    // binding-0 descriptor is re-pointed at the per-frame TAA output image. The
    // resolve's and the bloom prefilter's own inputs need no wiring: they
    // allocate their sets per frame from the shared post arena.
    let taa = if features.taa_enabled {
        let taa = TaaResources::new(&init_post_device, frames, targets.render_extent)?;
        for (i, &set) in composite.sets.iter().enumerate() {
            write_composite_set(
                device,
                set,
                taa.output_view(i),
                composite_bloom_view(&targets.transient_pool, scene.ssao_white.view, i),
                scene.color_lut.view,
            );
        }
        Some(taa)
    } else {
        None
    };

    // Temporal upscaling overrides the scene input: when FSR is active the
    // composite samples its reconstructed swapchain-res output (a single
    // shared image), not the per-frame TAA output. TAA
    // resources are forced built under upscaling (for the velocity pre-pass)
    // and the TAA block above pointed the sets at the TAA output, so this
    // override is the final word; the TAA *resolve* is dropped from the
    // graph and never runs.
    if let Some(up) = upscale {
        let up_output_view = up.output().image().view;
        for (i, &set) in composite.sets.iter().enumerate() {
            write_composite_set(
                device,
                set,
                up_output_view,
                composite_bloom_view(&targets.transient_pool, scene.ssao_white.view, i),
                scene.color_lut.view,
            );
        }
    }

    // Composite channel bindings (3..7), for the debug view modes. Written
    // after the re-wire above so they point at the merged pre-pass's views;
    // the 1x1 white fallback stands in when a world built no G-buffer / no
    // SSAO.
    for (i, &set) in composite.sets.iter().enumerate() {
        let channels =
            composite_channels(targets, screen.gbuffer.as_ref(), scene.ssao_white.view, i);
        write_composite_channel_set(device, set, &channels);
    }
    Ok(taa)
}

pub(super) struct WorldEffectInputs<'a> {
    pub(super) targets: &'a VkTargets,
    pub(super) descriptors: &'a VkDescriptors,
    pub(super) fx: &'a WorldFx,
    pub(super) planar: PlanarBudget,
    pub(super) cull: &'a VkCull,
    pub(super) n_cull: usize,
    pub(super) rt: &'a RtResources,
    pub(super) geometry: &'a VkGeometry,
    pub(super) post: &'a PostSettings,
}

pub(super) struct WorldEffects {
    pub(super) decal: DecalState,
    pub(super) fog: FogState,
    pub(super) raymarch: Option<RaymarchResources>,
    pub(super) planar_reflection: Option<PlanarReflectionSet>,
    pub(super) transparent: Option<TransparentResources>,
    pub(super) auto_exposure: AutoExposureState,
}

pub(super) fn build_world_effects(
    gpu: &InitGpu<'_>,
    inputs: WorldEffectInputs<'_>,
    bindings: &GlobalBindings<'_>,
) -> RenderResult<WorldEffects> {
    let InitGpu {
        hw,
        command_pool,
        frames,
        hot_reload,
    } = *gpu;
    let (device, alloc, graphics_queue) = (&hw.device, &hw.alloc, hw.graphics_queue);
    let WorldEffectInputs {
        targets,
        descriptors,
        fx,
        planar,
        cull,
        n_cull,
        rt,
        geometry,
        post,
    } = inputs;
    let GlobalBindings {
        uniforms,
        shadow,
        scene,
        ..
    } = *bindings;
    let (render_extent, msaa_samples) = (targets.render_extent, targets.msaa_samples);
    // Pipeline + per-frame uniforms + per-decal albedo sets are always
    // built so runtime `add_decal` works from a world that started
    // with none. The encoder simply skips when every slot is `None`
    // or every live decal culls.
    let depth_views: Vec<vk::ImageView> = targets.depth_images.iter().map(|img| img.view).collect();
    let hdr_resolve_views: Vec<vk::ImageView> = targets
        .hdr_resolve_images
        .iter()
        .map(|img| img.view)
        .collect();
    let decals_state = Some(crate::vulkan::decal::DecalResources::new(
        crate::vulkan::decal::DecalDeviceContext {
            alloc,
            device,
            command_pool,
            queue: graphics_queue,
        },
        crate::vulkan::decal::DecalPassTargets {
            hdr_format: HDR_FORMAT,
            hdr_resolve_views: &hdr_resolve_views,
            depth_views: &depth_views,
            sampler: scene.linear_sampler.handle(),
            extent: render_extent,
        },
        frames,
        msaa_samples != vk::SampleCountFlags::TYPE_1,
        hot_reload,
    )?);

    // Volumetric fog: pipeline + per-frame uniform ring. Built only
    // when the world declared a `VolumetricFog`; the encoder skips the
    // pass when the fog settings are `None`.
    let fog_resources = if fx.fog.is_some() {
        Some(crate::vulkan::fog::FogResources::new(
            crate::vulkan::fog::FogDeviceContext {
                alloc,
                device,
                command_pool,
                queue: graphics_queue,
            },
            crate::vulkan::fog::FogFrameTargets {
                frames,
                msaa: msaa_samples != vk::SampleCountFlags::TYPE_1,
                hdr_format: HDR_FORMAT,
                hdr_resolve_views: &hdr_resolve_views,
                depth_views: &depth_views,
                extent: render_extent,
            },
            crate::vulkan::fog::FogShadowResources {
                ubos: &shadow.ubos,
                map_view: shadow.map.view,
                sampler: shadow.sampler.handle(),
            },
            hot_reload,
        )?)
    } else {
        None
    };

    // Raymarched SDF volumes: per-volume pipelines + the shared view ring,
    // descriptor pool, render passes, and scene snapshot. `None` when the world
    // declares no `SdfVolume`, so the Raymarch pass is omitted from the frame graph.
    let raymarch = crate::vulkan::raymarch::RaymarchResources::try_new(
        crate::vulkan::raymarch::RaymarchDeviceContext {
            alloc,
            device,
            command_pool,
            queue: graphics_queue,
        },
        crate::vulkan::raymarch::RaymarchTargetConfig {
            frames,
            msaa_samples,
            width: render_extent.width,
            height: render_extent.height,
        },
        crate::vulkan::raymarch::RaymarchSharedBindings {
            shadow_map_view: shadow.map.view,
            shadow_sampler: shadow.sampler.handle(),
            irradiance_view: scene.env_map.irradiance.view,
            prefilter_view: scene.env_map.prefilter.view,
            cube_sampler: scene.cube_sampler.handle(),
            linear_sampler: scene.linear_sampler.handle(),
            light_ubos: &uniforms.light_ubo_buffers,
            shadow_ubos: &shadow.ubos,
            shadow_render_pass: shadow.render_pass.handle(),
        },
        &fx.sdf_volumes,
        hot_reload,
    )?;

    // Planar reflections: group each transparent reflector's world-space plane
    // into a bounded set of distinct planes (near-coplanar reflectors share one
    // mirror render; reflectors past the budget fall back to the probe cube),
    // then build one mirror target per distinct plane. Built before the
    // transparent pass so each record's planar binding can point at its plane's
    // target. `slots[i]` is reflector `i`'s target slot (or `None`), water first,
    // then glass, matching the other backends.
    let planar = PlanarReflectors::plan(&fx.water_surfaces, &fx.glass_panels, planar);
    let planar_slots = planar.assignment.slots.clone();
    if planar.overflow() > 0 {
        tracing::warn!(
            "planar reflection: {} reflector plane(s) exceed the budget and fall back \
             to the box-projected probe cube",
            planar.overflow()
        );
    }
    // The reflected-frustum mirror cull is bindless-only (it needs the GPU cull
    // set layout + the per-frame object/draw-args SSBOs); a non-bindless world
    // has no `cull_kernels`, so planar is skipped and its panes keep the
    // probe / sky reflection. Mirrors `metal::planar`'s bindless gate.
    let planar_reflection = if planar.planes().is_empty() {
        None
    } else if let Some(kernels) = cull.cull_kernels.as_ref() {
        let cull_sources = crate::vulkan::planar::PlanarCullSources {
            frame_object_buffers: &cull.object_buffers,
            frame_draw_args_buffers: &cull.draw_args_buffers,
            cull_set_layout: kernels.set_layout.handle(),
            cull_count: n_cull,
            hiz: cull
                .hiz
                .as_ref()
                .map(|h| (h.read_set_layout.handle(), h.read_set_view())),
        };
        Some(crate::vulkan::planar::PlanarReflectionSet::new(
            crate::vulkan::planar::PlanarDevice {
                alloc,
                device,
                command_pool,
                queue: graphics_queue,
            },
            crate::vulkan::planar::PlanarConfig {
                frames,
                sample_count: msaa_samples,
                width: render_extent.width,
                height: render_extent.height,
            },
            planar,
            &targets.main_render_pass,
            crate::vulkan::planar::PlanarGlobalSet {
                layout: descriptors.global_set_layout.handle(),
                bindings: *bindings,
            },
            cull_sources,
        )?)
    } else {
        None
    };
    let planar_target_views: Vec<vk::ImageView> = planar_reflection
        .as_ref()
        .map(|s| (0..s.plane_count()).map(|i| s.target_view(i)).collect())
        .unwrap_or_default();

    // The shared transparent pass and its producers: water surfaces,
    // translucent glass panes, and see-through glass meshes. `Some` only when
    // the world declared at least one of the three; the mesh case additionally
    // needs an RT-capable device, since its producer is ray-traced only and a
    // pane-less, water-less world would otherwise build the whole pass for a
    // producer that cannot exist. The pass blends into the post-reflection
    // scene image (the reflection composite output when a reflection path is
    // active, else this slot's HDR resolve), so the scene target per frame slot
    // is resolved here; the main-depth views feed the fragments' manual
    // occlusion test.
    let transparent = if fx.glass_panels.is_empty()
        && fx.water_surfaces.is_empty()
        && !rt.has_seethrough_meshes
    {
        None
    } else {
        let (scene_views, scene_images): (Vec<vk::ImageView>, Vec<vk::Image>) = (0..frames)
            .map(|i| {
                if let Some(c) = rt.composite.as_ref() {
                    (c.output().view(), c.output().image().image)
                } else {
                    (
                        targets.hdr_resolve_images[i].view,
                        targets.hdr_resolve_images[i].image,
                    )
                }
            })
            .unzip();
        let transparent_depth_views: Vec<vk::ImageView> =
            targets.depth_images.iter().map(|img| img.view).collect();
        let reactive_mask_views: Vec<vk::ImageView> = targets
            .reactive_mask_images
            .iter()
            .map(|img| img.view)
            .collect();
        // The initial acceleration-structure handles for the RT path (`None`
        // when RT is off at launch or there is no BVH yet; the per-frame
        // `rt_dynamic_update` fills the ring before the RT path is taken). The
        // RT pipelines themselves are built whenever the device is RT-capable.
        let rt_inputs = rt.state.accel.as_ref().map(|a| {
            let (geom_buffer, geom_size) = a.geom_table();
            crate::vulkan::transparent::TransparentRtInputs {
                tlas: a.tlas(),
                geom_buffer,
                geom_size,
                deformed_verts: a.deformed_verts(),
                skinned_indices: a.skinned_indices(),
            }
        });
        let (water_planar_slots, glass_planar_slots) =
            planar_slots.split_at(fx.water_surfaces.len());
        Some(crate::vulkan::transparent::TransparentResources::new(
            crate::vulkan::transparent::TransparentDeviceCtx {
                alloc,
                instance: &hw.instance,
                device,
                physical_device: hw.physical_device,
                command_pool,
                queue: graphics_queue,
            },
            crate::vulkan::transparent::TransparentBuildConfig {
                frames,
                msaa_samples,
                width: render_extent.width,
                height: render_extent.height,
                global_set_layout: descriptors.global_set_layout.handle(),
                hot_reload,
                reflection_divisor: post.rt_reflections.map_or(1, |rt| rt.divisor),
            },
            crate::vulkan::transparent::TransparentSceneTargets {
                scene_views: &scene_views,
                scene_images: &scene_images,
                reactive_mask_views: &reactive_mask_views,
                depth_views: &transparent_depth_views,
                sampler: scene.linear_sampler.handle(),
            },
            crate::vulkan::transparent::TransparentContent {
                glass_panels: &fx.glass_panels,
                glass_planar_slots,
                water_surfaces: &fx.water_surfaces,
                water_planar_slots,
                planar_target_views: &planar_target_views,
                seethrough_mesh_indices: &rt.seethrough_mesh_indices,
            },
            crate::vulkan::transparent::TransparentRtSetup {
                rt_capable: hw.rt_capable,
                vertex_buffer: geometry.vertex_buffer.buffer(),
                index_buffer: geometry.index_buffer.buffer(),
                rt_inputs,
                bindless_set_layout: cull.bindless_set_layout.as_ref().map(|l| l.handle()),
                bindless_pool_size: cull.bindless_pool_size,
            },
        )?)
    };

    // Auto-exposure (EV adaptation): histogram + average compute
    // pipelines, the device-local histogram + output buffers, and the
    // per-frame readback ring. Built only when the world's
    // `PostProcessConfig` opted in. With auto-exposure off every
    // field below is None and the static authored EV continues to
    // drive `post_process.exposure` unchanged.
    let (auto_exposure, adaptation) = if let Some(settings) = post.auto_exposure.as_ref() {
        let resources = crate::vulkan::auto_exposure::AutoExposureResources::new(
            alloc,
            device,
            frames,
            &hdr_resolve_views,
            hot_reload,
        )?;
        let adaptation =
            auto_exposure::ExposureAdaptation::new(*settings, post.auto_exposure_bias_ev);
        (Some(resources), Some(adaptation))
    } else {
        (None, None)
    };
    Ok(WorldEffects {
        decal: DecalState {
            resources: decals_state,
            // Authored decals land in the table through `add_decal`, which
            // also writes each one's albedo descriptor.
            set: decal::DecalSet::new(crate::vulkan::decal::MAX_DECALS, frames),
        },
        // The fog sun mirrors the first directional light, cached because the
        // light UBO is uploaded rather than pushed each frame.
        // `update_directional_lights` re-derives both.
        fog: FogState {
            resources: fog_resources,
            settings: fx.fog,
            sun_dir: lights::sun_direction(&uniforms.light_uniforms),
            sun_color: lights::sun_color(&uniforms.light_uniforms),
        },
        raymarch,
        planar_reflection,
        transparent,
        auto_exposure: AutoExposureState {
            resources: auto_exposure,
            adaptation,
            last_elapsed: 0.0,
        },
    })
}