concinnity-device 0.19.53

GPU backends (Metal, Vulkan, DirectX) behind a device facade for Concinnity
//! Runtime application of the Quality-group settings (TAA / SSAO / SSR / SSGI /
//! auto-exposure). Each gates a render pass whose GPU resources (pipelines,
//! render targets, descriptor sets) are built once at init from the world's
//! PostProcessConfig, so applying a change at runtime means building or tearing
//! down those resources, not flipping a uniform.
//!
//! The reconcile below brings each feature's `Option` field to the desired state
//! (constructing a turning-on feature with the same `*Resources::new` the init
//! path runs, tearing down a turning-off one), then defers the whole target
//! rebuild + descriptor rewire to `rebuild_swapchain` -- the exact path a window
//! resize takes. Reusing it means a live toggle produces resources rewired
//! identically to a launch with the same config, with no second copy of the
//! intricate per-reader rewiring to drift. Bloom, decals, fog, particles, and
//! the uploaded geometry are untouched.
//!
//! Ray-traced reflections toggle the same way, with two extra costs: turning
//! them on builds the scene acceleration structure (`build_rt_accel`, a one-shot
//! fence-waited BLAS + TLAS over current geometry) plus the inline-`rayQueryEXT`
//! reflection pass, so a live enable hitches once proportional to triangle count.
//! And RT is only live-toggleable when the device is RT-capable -- the ray-query
//! device extensions are enabled at creation whenever capable (see
//! `create_logical_device`), since an extension cannot be added later; on an
//! RT-incapable GPU or under XeSS the toggle no-ops with a warning and RT stays
//! whatever it launched as (persisted for the next launch).

use ash::vk;
use concinnity_core::gfx::auto_exposure;
use concinnity_core::render::backend::QualitySettings;
use concinnity_core::render::error::RenderResult;
use concinnity_core::render::post::rt_reflections;

use super::context::VkContext;

impl VkContext {
    // Bring the toggle-controlled features to match `q`, applied between frames
    // (the GraphicsSystem reads the SettingCommand before the next draw_frame).
    // A build failure returns early and leaves the prior state intact.
    pub(crate) fn apply_quality_settings(&mut self, q: QualitySettings) -> RenderResult<()> {
        // Every teardown / rebuild below frees or replaces GPU resources a prior
        // frame may still reference; drain the device first so the swap is safe.
        self.wait_idle();

        // Desired enabled state per feature, from the resolved QualitySettings.
        // RT is additionally gated on the device being RT-capable: a non-capable
        // device (or XeSS) did not enable the ray-query extensions at creation,
        // so it cannot build the acceleration structure at runtime -- the toggle
        // no-ops with a warning and RT stays whatever it launched as.
        let desired_rt = q.rt_reflections.is_some() && self.hw.rt_capable;
        if q.rt_reflections.is_some() && !self.hw.rt_capable {
            tracing::warn!(
                "ray-traced reflections requested but the device is not RT-capable \
                 (no ray-query extensions / XeSS active); keeping SSR"
            );
        }
        let desired_ssr = q.ssr.is_some();
        let desired_ssgi = q.ssgi.is_some();
        let desired_ssao = q.ssao.is_some();
        let desired_ae = q.auto_exposure.is_some();
        // TAA resources are forced present while temporal upscaling is active
        // (the upscaler consumes the velocity pre-pass); the TAA resolve is then
        // dropped from the graph. Mirrors the init `taa_enabled` derivation.
        let upscale_on = self.upscale.is_some();
        let desired_taa = q.taa || upscale_on;

        // The SSR pre-pass resources (`SsrResources`) exist whenever SSR, SSGI,
        // or RT is on (SSGI and RT reuse the SSR resolve's plumbing); mirrors the
        // init `ssr_opt` gate. The unified G-buffer pre-pass is needed by any
        // screen-space consumer of the merged buffer (RT reads its per-frame
        // normal+depth and roughness).
        let ssr_needed = desired_ssr || desired_ssgi || desired_rt;
        let gbuffer_needed = ssr_needed || desired_ssao || desired_taa;

        let hdr_views: Vec<vk::ImageView> = self
            .targets
            .hdr_resolve_images
            .iter()
            .map(|i| i.view)
            .collect();

        // Unified G-buffer pre-pass (shared dependency): build it before any
        // consumer that samples it. Kept alive once built (a later toggle-off of
        // the last consumer leaves it resident until the next launch / resize),
        // which is harmless: with no consumer the graph omits its readers.
        if gbuffer_needed && self.gbuffer.is_none() {
            // Its three color channels are pool-owned, so the pool has to place
            // them before the pre-pass framebuffers can reference them. Rebuild
            // with the G-buffer gate on first; the `rebuild_swapchain` later in
            // this call rebuilds the pool once more and re-points every reader.
            self.targets.transient_pool.rebuild(
                &super::transient_pool::TransientPoolGpu {
                    instance: &self.hw.instance,
                    device: &self.hw.device,
                    physical_device: self.hw.physical_device,
                    command_pool: self.commands.command_pool,
                    queue: self.hw.graphics_queue,
                },
                self.frames_in_flight,
                &super::transient_pool::transient_slots(
                    self.ssao.is_some(),
                    self.post_process.bloom_intensity > 0.0,
                    true,
                    self.targets.render_extent,
                    self.swapchain.extent,
                )?,
            )?;
            let pooled = self
                .targets
                .transient_pool
                .gbuffer_pooled(self.frames_in_flight);
            let gb = super::post::gbuffer::GbufferResources::new(
                super::post::gbuffer::GbufferDeviceCtx {
                    alloc: &self.hw.alloc,
                    device: &self.hw.device,
                },
                super::post::gbuffer::GbufferQueueCtx {
                    command_pool: self.commands.command_pool,
                    queue: self.hw.graphics_queue,
                },
                super::post::gbuffer::GbufferExtent {
                    width: self.targets.render_extent.width,
                    height: self.targets.render_extent.height,
                    frames: self.frames_in_flight,
                },
                &pooled,
            )?;
            self.gbuffer = Some(gb);
        }

        // TAA.
        if desired_taa && self.taa.is_none() {
            let taa = super::post::taa::TaaResources::new(
                &self.post_device(0),
                self.frames_in_flight,
                self.targets.render_extent,
            )?;
            self.taa = Some(taa);
        } else if !desired_taa && self.taa.is_some() {
            // The cached framebuffers name the accumulation images' views, so
            // they go before the images do.
            self.post.cache.forget_views();
            self.taa = None;
        }

        // SSR resolve + reflection target. Built whenever SSR / SSGI / RT is on;
        // its settings carry whether SSR itself is authored, so a build kept alive
        // for SSGI or RT takes an SSR toggle through them alone.
        if ssr_needed && self.ssr.is_none() {
            let ssr = super::post::ssr::SsrResources::new(
                &self.post_device(0),
                q.ssr,
                self.targets.render_extent,
            )?;
            self.ssr = Some(ssr);
        } else if !ssr_needed && self.ssr.is_some() {
            // The cached framebuffers name the reflection target's view, so they
            // go before it does.
            self.post.cache.forget_views();
            self.ssr = None;
        } else if let Some(ssr) = self.ssr.as_mut() {
            ssr.settings = q.ssr;
        }

        // SSGI (samples the unified G-buffer's per-frame normal+depth views).
        if desired_ssgi && self.ssgi.is_none() {
            let settings = q.ssgi.expect("desired_ssgi implies ssgi settings");
            let ssgi = super::post::ssgi::SsgiResources::new(
                &self.post_device(0),
                settings,
                self.targets.render_extent,
            )?;
            self.ssgi = Some(ssgi);
        } else if !desired_ssgi && self.ssgi.is_some() {
            self.post.cache.forget_views();
            self.ssgi = None;
        }

        // Auto-exposure. When it turns off the static authored EV drives exposure
        // again (the GraphicsSystem re-pushes `update_post_process` after this
        // call), so only the GPU state is swapped here.
        if desired_ae && self.auto_exposure.resources.is_none() {
            let settings = q
                .auto_exposure
                .as_ref()
                .expect("desired_ae implies auto-exposure settings");
            let resources = crate::vulkan::auto_exposure::AutoExposureResources::new(
                &self.hw.alloc,
                &self.hw.device,
                self.frames_in_flight,
                &hdr_views,
                self.hot_reload.enabled,
            )?;
            self.auto_exposure.resources = Some(resources);
            self.auto_exposure.state = Some(auto_exposure::AutoExposureState::new(settings));
            self.auto_exposure.settings = q.auto_exposure;
            self.auto_exposure.bias_ev = q.auto_exposure_bias_ev;
        } else if !desired_ae && self.auto_exposure.resources.is_some() {
            let mut ae = self
                .auto_exposure
                .resources
                .take()
                .expect("auto-exposure present");
            ae.destroy(&self.hw.device);
            self.auto_exposure.settings = None;
            self.auto_exposure.state = None;
        }

        // SSAO. Its occlusion target is the transient pool's per-frame
        // `ao_output`, which only exists while SSAO is on, so turning it on means
        // rebuilding the pool (to add `ao_output`) before constructing the SSAO
        // resources. `rebuild_swapchain` below rebuilds the pool again from the
        // now-Some `self.ssao`, then re-points binding 6 at the rebuilt views.
        if desired_ssao && self.ssao.is_none() {
            self.targets.transient_pool.rebuild(
                &super::transient_pool::TransientPoolGpu {
                    instance: &self.hw.instance,
                    device: &self.hw.device,
                    physical_device: self.hw.physical_device,
                    command_pool: self.commands.command_pool,
                    queue: self.hw.graphics_queue,
                },
                self.frames_in_flight,
                &super::transient_pool::transient_slots(
                    true,
                    self.post_process.bloom_intensity > 0.0,
                    self.gbuffer.is_some(),
                    self.targets.render_extent,
                    self.swapchain.extent,
                )?,
            )?;
            let settings = q.ssao.expect("desired_ssao implies ssao settings");
            let ao_views = self
                .targets
                .transient_pool
                .views_for_frames("ao_output", self.frames_in_flight);
            let ssao = super::post::ssao::SsaoResources::new(
                &super::post::ssao::SsaoDeviceCtx {
                    alloc: &self.hw.alloc,
                    device: &self.hw.device,
                },
                self.targets.render_extent.width,
                self.targets.render_extent.height,
                self.frames_in_flight,
                settings,
                &ao_views,
                self.hot_reload.enabled,
            )?;
            self.ssao = Some(ssao);
        } else if !desired_ssao && self.ssao.is_some() {
            let mut ssao = self.ssao.take().expect("ssao present");
            ssao.destroy(&self.hw.device);
        }

        // Ray-traced reflections. Turning on builds the scene acceleration
        // structure (one-shot, fence-waited) + the inline-`rayQueryEXT` pass;
        // turning off tears both down. The G-buffer pre-pass RT samples is
        // already built above (`gbuffer_needed` folds in `desired_rt`).
        // `rebuild_swapchain` below then rebuilds the RT output target; the
        // per-frame TLAS / geometry descriptors are wired by the next
        // `rt_dynamic_update`.
        if let (true, Some(settings), Some(rt)) =
            (desired_rt, q.rt_reflections, self.rt_reflections.as_mut())
        {
            // Already live: take the new trace resolution / shadow choice, which
            // `rebuild_swapchain` below sizes the output target from.
            rt.settings = settings;
        } else if desired_rt && self.rt_reflections.is_none() {
            self.build_rt_runtime(q.rt_reflections.expect("desired_rt implies settings"))?;
        } else if !desired_rt && self.rt_reflections.is_some() {
            if let Some(mut rt) = self.rt_reflections.take() {
                rt.destroy(&self.hw.device);
            }
            if let Some(mut accel) = self.rt.accel.take() {
                accel.destroy(&self.hw.device);
            }
        }

        // The composite follows the ACTUAL post-build RT state, so a failed RT
        // enable falls back to the SSR resolve. `rebuild_swapchain` below then
        // routes the scene image through whichever path is left.
        self.reconcile_reflection_composite(q.reflection_blur_scale)?;

        // Rebuild every target + rewire every reader / the composite chain via
        // the resize path. It rebuilds the transient pool + bloom from the
        // reconciled `self.ssao`, rebuilds each `Some` feature's targets, and
        // re-points the bloom prefilter + composite scene input down the
        // upscale > TAA > reflection-composite > HDR priority chain.
        self.rebuild_swapchain()
    }

    // Build the RT acceleration structure + reflection pass at runtime (a live
    // toggle-on). Mirrors the init RT block: an empty scene, an AS-build error,
    // or a shader-compile failure leaves both `rt.accel` / `rt_reflections`
    // `None` and the renderer falls back to the SSR resolve when authored (a soft
    // failure, returns `Ok`). The
    // caller has ensured the unified G-buffer pre-pass exists and drained the
    // device (`wait_idle`). `rebuild_swapchain` refreshes the output target after.
    fn build_rt_runtime(
        &mut self,
        settings: rt_reflections::RtReflectionSettings,
    ) -> RenderResult<()> {
        let accel = match crate::vulkan::raytrace::build_rt_accel(
            crate::vulkan::raytrace::RtDeviceCtx {
                alloc: &self.hw.alloc,
                instance: &self.hw.instance,
                device: &self.hw.device,
                pd: self.hw.physical_device,
            },
            self.commands.command_pool,
            self.hw.graphics_queue,
            crate::vulkan::raytrace::RtSceneGeometry {
                vertex_buffer: self.geometry.vertex_buffer.buffer(),
                index_buffer: self.geometry.index_buffer.buffer(),
                draw_objects: &self.draw.objects,
                clusters: &self.instanced.clusters,
                albedo_count: self.scene.textures.len(),
                total_vertices: self.rt.static_vertex_count,
                exclude_seethrough: self.seethrough_meshes_enabled(),
            },
            self.frames_in_flight,
            self.hot_reload.enabled,
        ) {
            Ok(Some(accel)) => accel,
            Ok(None) => {
                tracing::info!(
                    "RT reflections enabled but no resident triangle geometry to trace; keeping SSR"
                );
                return Ok(());
            }
            Err(e) => {
                tracing::warn!("RT acceleration-structure build failed (keeping SSR): {e}");
                return Ok(());
            }
        };

        let hdr_views: Vec<vk::ImageView> = self
            .targets
            .hdr_resolve_images
            .iter()
            .map(|i| i.view)
            .collect();
        let gb = self
            .gbuffer
            .as_ref()
            .expect("RT enable forces the unified G-buffer pre-pass on");
        let nd_views = gb.normal_depth_views();
        let rough_views = gb.roughness_views();
        let (geom_buffer, geom_size) = accel.geom_table();
        // The textured hit variant indexes the bindless pool, so it compiles
        // against the length the pool set layout was built with; 0 when there
        // is no bindless layout, in which case the variant is not built.
        let bindless_pool_size = self.cull.bindless_pool_size;
        let rt = match super::post::rt_reflections::RtReflectionsResources::new(
            super::post::rt_reflections::RtBuild {
                alloc: &self.hw.alloc,
                device: &self.hw.device,
                width: self.targets.render_extent.width,
                height: self.targets.render_extent.height,
                frames: self.frames_in_flight,
            },
            settings,
            super::post::rt_reflections::RtStaticInputs {
                vertex_buffer: self.geometry.vertex_buffer.buffer(),
                index_buffer: self.geometry.index_buffer.buffer(),
                hdr_resolve_views: &hdr_views,
                gbuffer_views: &nd_views,
                roughness_views: &rough_views,
            },
            super::post::rt_reflections::RtAccelHandles {
                tlas: accel.tlas(),
                geom_buffer,
                geom_size,
                deformed_verts: accel.deformed_verts(),
                skinned_indices: accel.skinned_indices(),
            },
            super::post::rt_reflections::RtLayoutConfig {
                bindless_set_layout: self.cull.bindless_set_layout.as_ref().map(|l| l.handle()),
                global_set_layout: self.descriptors.global_set_layout.handle(),
                pool_size: bindless_pool_size,
                hot_reload: self.hot_reload.enabled,
            },
        ) {
            Ok(rt) => rt,
            Err(e) => {
                tracing::warn!("RT reflections pass build failed (keeping SSR): {e}");
                let mut accel = accel;
                accel.destroy(&self.hw.device);
                return Ok(());
            }
        };
        self.rt.accel = Some(accel);
        self.rt_reflections = Some(rt);
        Ok(())
    }
}