concinnity-device 0.19.119

GPU backends (Metal, Vulkan, DirectX) behind a device facade for Concinnity
//! Texture-pool slot management for VkContext: bindless-pool, decal and
//! particle descriptor rewires when an albedo or normal-map slot is streamed in
//! or evicted. Mirrors the Metal pattern of "the texture pool gets re-read every
//! frame," except Vulkan bakes texture *views* into descriptor sets, so a slot
//! swap must walk every set that samples this slot.

use ash::vk;
use concinnity_core::bake;
use concinnity_core::render::error;
use concinnity_core::render::error::{RenderError, RenderResult};

use super::super::context::*;
use super::super::descriptor_layout::{IRRADIANCE_CUBE_BINDING, PREFILTER_CUBE_BINDING};
use super::super::set_writes::SetWrites;
use super::super::texture::{
    GpuUploadContext, StreamedUploadRetire, upload_texture_image, upload_texture_image_deferred,
};

impl VkContext {
    // Re-point bindless texture-pool element `index` of `set` to `view`.
    // Keeps the bindless texture pool in sync with a streamed texture swap; the
    // pool is one handle-indexed image set (albedo + normal maps share it).
    fn write_pool_image(&self, set: vk::DescriptorSet, index: u32, view: vk::ImageView) {
        SetWrites::new(set)
            .sampled_image_at(1, index, view, vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
            .apply(&self.hw.device);
    }

    // Re-point every descriptor that samples texture-pool `slot` and may be
    // referenced by in-flight command buffers: the per-frame bindless pool
    // copies plus the decal / clone / particle sets. Only legal under a device
    // drain (the fallback streaming path and the `cn debug` hot-reload paths).
    fn rewrite_bound_texture_sets(&self, slot: usize) {
        let view = self.scene.textures[slot].view;
        // The bindless pool addresses each texture at pool index == its handle,
        // shared by albedo and normal sampling, so one re-point covers both.
        for &set in &self.cull.bindless_sets {
            self.write_pool_image(set, slot as u32, view);
        }
        // Per-decal albedo descriptors. Walk the decal-side slot tracker;
        // a world with no decals pays nothing here.
        self.rewrite_decal_albedo_slot(slot);
        // Particle emitters sample an albedo from the texture pool into their
        // own render set, so re-point any whose source slot is this one.
        self.rewrite_particle_albedo_slot(slot);
    }

    // Re-point every descriptor that samples texture-pool `slot`. Only legal
    // under a device drain. An in-flight reflection-probe bake needs no arm
    // here: each bake face snapshots the live pool into its own set right
    // before recording.
    fn rewrite_texture_slot(&self, slot: usize) {
        self.rewrite_bound_texture_sets(slot);
    }

    // Whether replacing pool `slot` must drain the device first: true when a
    // descriptor that samples the slot may be referenced by pending command
    // buffers AND cannot wait for the per-frame propagation. The bindless pool
    // copies propagate per frame slot, so what remains are the single-copy
    // sets of the decal and particle passes, and a world with nothing to
    // GPU-drive, which draws nothing.
    fn streamed_slot_needs_drain(&self, slot: usize) -> bool {
        let bindless_active = self.cull.bindless_pipeline.is_some() && self.cull_count() > 0;
        if !bindless_active {
            return true;
        }
        self.decal_samples_slot(slot) || self.particle_samples_slot(slot)
    }

    // Replace albedo texture-pool `slot` with a freshly decoded texture.
    //
    // The streaming fast path never stalls the device: the upload is
    // submitted without waiting (later submissions on the queue order after
    // its final barrier, so any frame recorded from here on samples it
    // safely), the per-frame bindless pool copies re-point one per frame as
    // their fences retire, and the old image
    // plus upload transients are parked on `stream.retires` until every
    // consumer provably moved off them. When a pending-referenced single-copy
    // set samples the slot (see `streamed_slot_needs_drain`) the swap instead
    // drains the device and rewrites everything in place, matching the
    // hot-reload paths below.
    pub(crate) fn update_texture_slot(
        &mut self,
        slot: usize,
        image: &bake::texture::TextureImage,
    ) -> error::RenderResult<()> {
        if slot >= self.scene.textures.len() {
            return Err(RenderError::Other(format!(
                "update_texture_slot: slot {} out of range (pool size {})",
                slot,
                self.scene.textures.len()
            )));
        }
        let ctx = GpuUploadContext {
            alloc: &self.hw.alloc,
            device: &self.hw.device,
            command_pool: self.commands.command_pool,
            queue: self.hw.graphics_queue,
        };
        if self.streamed_slot_needs_drain(slot) {
            self.wait_idle();
            let img = upload_texture_image(&ctx, image)?;
            // Swap in the new image, then rewrite every descriptor that
            // samples this slot BEFORE destroying the old view. The reverse
            // order left a brief window where descriptor sets referenced an
            // already-destroyed VkImageView, which validation layers and some
            // drivers flag even though vkUpdateDescriptorSets is write-only.
            let old = std::mem::replace(&mut self.scene.textures[slot], img);
            self.rewrite_texture_slot(slot);
            // The full rewrite covered every per-frame pool copy, so any
            // propagation queued for this slot is already satisfied.
            self.stream.pool_rewrites.remove(slot);
            drop(old);
            return Ok(());
        }
        let (img, in_flight) = upload_texture_image_deferred(&ctx, image)?;
        let old = std::mem::replace(&mut self.scene.textures[slot], img);
        self.stream.pool_rewrites.queue(slot);
        self.stream.retires.push(
            self.stream.frame,
            StreamedUploadRetire {
                _image: old,
                _staging: in_flight.staging,
                cmd: in_flight.cmd,
            },
        );
        Ok(())
    }

    // Reset texture-pool `slot` to a 1x1 mid-gray placeholder.
    pub(crate) fn evict_texture_slot(&mut self, slot: usize) -> RenderResult<()> {
        let gray = bake::texture::TextureImage::rgba8(1, 1, vec![128, 128, 128, 255]);
        self.update_texture_slot(slot, &gray)
    }

    // Per-frame streamed-texture upkeep, called at the top of `draw_frame`
    // right after frame slot `frame`'s fence wait: re-point this slot's
    // bindless pool copy at any swapped slots (legal now -- the wait retired
    // every command buffer that binds this copy), then free retires whose
    // covering fence has signaled.
    pub(in crate::vulkan) fn apply_streamed_texture_rewrites(&mut self, frame: usize) {
        self.stream.frame += 1;
        if !self.stream.pool_rewrites.is_empty() {
            let last = self.scene.textures.len().saturating_sub(1);
            for slot in self.stream.pool_rewrites.begin_frame() {
                let view = self.scene.textures[slot.min(last)].view;
                if let Some(&set) = self.cull.bindless_sets.get(frame) {
                    self.write_pool_image(set, slot as u32, view);
                }
            }
        }
        let now = self.stream.frame;
        let depth = self.stream.retire_depth;
        while let Some(retire) = self.stream.retires.pop_due(now, depth) {
            retire.destroy(&self.hw.device, self.commands.command_pool);
        }
    }

    // Free every parked streamed-texture retire immediately. Only legal after
    // a device drain; the world-reload and drop paths call this before
    // tearing the pool down.
    pub(in crate::vulkan) fn drain_stream_retires(&mut self) {
        for retire in self.stream.retires.drain() {
            retire.destroy(&self.hw.device, self.commands.command_pool);
        }
    }

    // Replace the live color-grading LUT with a fresh `size³` RGBA8 payload.
    // Driven by asset hot-reload (`cn debug` only) when the file-backed
    // `ColorLut` source is saved. `wait_idle` first guarantees no in-flight
    // command buffer still references the old image. Builds the replacement
    // via the same `upload_color_lut` the init path uses, rewrites every
    // composite descriptor set's binding 2 to point at the new view, then
    // drops the previous image; same write-then-destroy order as the
    // texture-pool rewires above to keep validation layers happy. Mirrors
    // `DxContext::update_color_lut` / `MtlContext::update_color_lut`. Reached
    // only through the bin's `cn debug` runtime-mutation path (dead in the FFI
    // lib, live in the bin).
    pub(crate) fn update_color_lut(&mut self, size: u32, data: &[u8]) -> RenderResult<()> {
        self.wait_idle();
        let new_lut = super::super::texture::upload_color_lut(
            &GpuUploadContext {
                alloc: &self.hw.alloc,
                device: &self.hw.device,
                command_pool: self.commands.command_pool,
                queue: self.hw.graphics_queue,
            },
            size,
            data,
        )?;
        // Rewrite composite descriptors before destroying the old image; see
        // the texture-pool rewires above for the rationale.
        let new_view = new_lut.view;
        let old = std::mem::replace(&mut self.scene.color_lut, new_lut);
        for &set in &self.composite.sets {
            SetWrites::new(set)
                .sampled_image(2, new_view)
                .apply(&self.hw.device);
        }
        drop(old);
        Ok(())
    }

    // Swap the live IBL cubemap pair for a freshly precomputed envmap payload.
    // Driven by asset hot-reload (`cn debug` only). Decodes the byte stream
    // emitted by `gfx::build::environment_map::serialize`, then re-uploads
    // the irradiance + prefilter cubes via the same `upload_environment_map`
    // the init path uses. Every consumer that captured the old image views is
    // re-pointed at the new ones: every global set (each frame's, the planar
    // mirrors' and an in-flight probe capture's faces) and the raymarch view
    // sets. `prefilter_mip_count` is refreshed on `self` so the next frame's
    // `ViewUniforms` upload picks up the new mip count, and the in-flight probe
    // capture's face views are rewritten with it. Unlike DirectX,
    // which re-uploads into the same SRV heap slots so its consumers need no
    // re-wire, every Vulkan `upload_environment_map` mints fresh `vk::ImageView`
    // handles, so each descriptor set must be re-written. Mirrors
    // `DxContext::update_environment_map`. Reached
    // only through the bin's `cn debug` runtime-mutation path (dead in the FFI
    // lib, live in the bin).
    pub(crate) fn update_environment_map(&mut self, payload: &[u8]) -> RenderResult<()> {
        let view = bake::environment_map::deserialize(payload).map_err(|e| {
            error::RenderError::Other(format!("envmap hot-reload payload malformed: {e}"))
        })?;
        self.wait_idle();
        let new_env = super::super::texture::upload_environment_map(
            &GpuUploadContext {
                alloc: &self.hw.alloc,
                device: &self.hw.device,
                command_pool: self.commands.command_pool,
                queue: self.hw.graphics_queue,
            },
            view.irradiance_face,
            view.irradiance_bytes,
            view.prefilter_face,
            &view.prefilter_mip_bytes,
        )?;
        let new_irradiance_view = new_env.irradiance.view;
        let new_prefilter_view = new_env.prefilter.view;
        let new_mip_count = new_env.prefilter_mip_count;
        // Rewrite every set before destroying the previous cubes; see the
        // texture-pool rewires above for the rationale.
        let old = std::mem::replace(&mut self.scene.env_map, new_env);
        self.scene.prefilter_mip_count = new_mip_count;
        self.rewrite_global_binding(IRRADIANCE_CUBE_BINDING);
        self.rewrite_global_binding(PREFILTER_CUBE_BINDING);
        self.rewrite_probe_capture_views();
        // The raymarch view sets sample both cubes outside the global set. The
        // SSR resolve reads the prefilter cube per frame, so it needs no
        // re-point.
        if let Some(rm) = self.raymarch.as_ref() {
            rm.rewire_ibl_cubes(&self.hw.device, new_irradiance_view, new_prefilter_view);
        }
        drop(old);
        Ok(())
    }
}