concinnity-render 0.18.64

GPU-free render preparation for the Concinnity engine
Documentation
//! Backend-agnostic planner for the incremental RT acceleration-structure
//! topology refresh. When the participating draw set changes at runtime (a
//! cloned prop, a streamed chunk added/removed), the BLAS head must be brought
//! back in line with the current set without rebuilding every BLAS: reuse every
//! BLAS whose geometry slice is unchanged, build only the new ones, and retire
//! the orphans. This module owns only the pure decision (which slot reuses which
//! old BLAS, which are orphaned); the actual GPU allocation / build / retire is
//! per-backend (directx/raytrace.rs, vulkan/raytrace.rs). Split out so the plan
//! is unit-testable without a GPU.
//!
//! Consumed by the DirectX + Vulkan backends. The Metal backend predates this
//! module and keeps its own equivalent copy (metal/raytrace.rs); a future
//! cleanup could converge it here once Metal can be rebuilt alongside.

use crate::render_types::DrawObject;
use alloc::vec;
use alloc::vec::Vec;

/// Identifies the geometry a draw-object BLAS traces, on the shared
/// vertex/index buffers. Two draw objects with the same signature trace
/// identical geometry, so a topology refresh can reuse the existing BLAS instead
/// of building a new one. A streamed mesh is placed wherever the sub-allocator
/// has room, so a slot that streams out and back in generally returns on a
/// different slice; the signature moves with it and the BLAS is rebuilt rather
/// than wrongly reused. `base_vertex` + `index_offset` + `index_count` are
/// exactly the inputs the per-backend geometry descriptor uses; `vertex_offset`
/// is carried too so a static draw (whose `base_vertex` is 0) still
/// distinguishes distinct vertex regions.
///
/// The slice location alone is not enough: an asset hot-reload rewrites a slot's
/// bytes in place at unchanged offsets, which leaves every field above equal.
/// `generation` (the draw object's `geometry_generation`) moves on each such
/// rewrite so the stale BLAS is rebuilt instead of reused.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct GeomSig {
    base_vertex: i32,
    vertex_offset: usize,
    index_offset: usize,
    index_count: usize,
    generation: u32,
}

impl GeomSig {
    /// The topology class a draw record falls into.
    pub fn of(obj: &DrawObject) -> Self {
        Self {
            base_vertex: obj.base_vertex,
            vertex_offset: obj.vertex_offset,
            index_offset: obj.index_offset,
            index_count: obj.index_count,
            generation: obj.geometry_generation,
        }
    }
}

/// Per-new-slot decision for a topology refresh of the draw-object BLAS head.
pub struct TopologyPlan {
    /// `reuse[j] == Some(k)`: new draw slot `j` reuses the old draw BLAS at index
    /// `k` (its geometry is unchanged). `None`: build a fresh BLAS for slot `j`.
    pub reuse: Vec<Option<usize>>,
    /// Old draw BLAS indices no longer referenced by any new slot -- retire them.
    pub retire: Vec<usize>,
}

/// Decide, for the draw-object BLAS head only, which BLAS to reuse, which to
/// build, and which to retire when the participating draw set changes. Matches
/// old and new slots by `draw_objects` index AND geometry signature: a slot whose
/// geometry moved (a chunk slot recycled for a different chunk) does not match, so
/// it rebuilds. Pure so it is unit-testable without a GPU.
pub fn plan_topology_refresh(
    old_indices: &[usize],
    old_sigs: &[GeomSig],
    new_indices: &[usize],
    new_sigs: &[GeomSig],
) -> TopologyPlan {
    use hashbrown::HashMap;
    // draw_objects index -> (position in the old draw BLAS head, its signature).
    // `object_indices` entries are unique (one per draw slot), so this is 1:1.
    let mut by_idx: HashMap<usize, (usize, GeomSig)> = HashMap::with_capacity(old_indices.len());
    for (k, (&idx, &sig)) in old_indices.iter().zip(old_sigs).enumerate() {
        by_idx.insert(idx, (k, sig));
    }
    let mut used = vec![false; old_indices.len()];
    let mut reuse = Vec::with_capacity(new_indices.len());
    for (&idx, &sig) in new_indices.iter().zip(new_sigs) {
        match by_idx.get(&idx) {
            Some(&(k, old_sig)) if old_sig == sig && !used[k] => {
                used[k] = true;
                reuse.push(Some(k));
            }
            _ => reuse.push(None),
        }
    }
    let retire = used
        .iter()
        .enumerate()
        .filter(|&(_, &u)| !u)
        .map(|(k, _)| k)
        .collect();
    TopologyPlan { reuse, retire }
}

#[cfg(test)]
mod tests {
    use super::*;

    // A distinct geometry signature keyed off `tag` (used as the index offset),
    // so two slots with different tags never compare equal.
    fn sig(tag: usize) -> GeomSig {
        GeomSig {
            base_vertex: tag as i32,
            vertex_offset: tag * 100,
            index_offset: tag,
            index_count: 3,
            generation: 0,
        }
    }

    #[test]
    fn topology_plan_reuses_an_unchanged_set() {
        let old_i = [2usize, 5, 7];
        let old_s = [sig(2), sig(5), sig(7)];
        let plan = plan_topology_refresh(&old_i, &old_s, &old_i, &old_s);
        assert_eq!(plan.reuse, vec![Some(0), Some(1), Some(2)]);
        assert!(plan.retire.is_empty());
    }

    #[test]
    fn topology_plan_builds_only_the_added_slot() {
        let old_i = [2usize, 5];
        let old_s = [sig(2), sig(5)];
        let new_i = [2usize, 5, 9];
        let new_s = [sig(2), sig(5), sig(9)];
        let plan = plan_topology_refresh(&old_i, &old_s, &new_i, &new_s);
        // The two existing slots reuse; the new one (9) builds fresh.
        assert_eq!(plan.reuse, vec![Some(0), Some(1), None]);
        assert!(plan.retire.is_empty());
    }

    #[test]
    fn topology_plan_retires_a_removed_slot() {
        let old_i = [2usize, 5, 7];
        let old_s = [sig(2), sig(5), sig(7)];
        let new_i = [2usize, 7];
        let new_s = [sig(2), sig(7)];
        let plan = plan_topology_refresh(&old_i, &old_s, &new_i, &new_s);
        assert_eq!(plan.reuse, vec![Some(0), Some(2)]);
        assert_eq!(plan.retire, vec![1]); // slot 5's old BLAS is orphaned
    }

    #[test]
    fn topology_plan_rebuilds_a_recycled_slot_whose_geometry_moved() {
        // Same draw index, different geometry signature: a chunk slot recycled for
        // a different chunk. The old BLAS must NOT be reused; it is retired and a
        // fresh one is built.
        let old_i = [5usize];
        let old_s = [sig(5)];
        let new_i = [5usize];
        let new_s = [sig(8)]; // moved geometry under the same draw index
        let plan = plan_topology_refresh(&old_i, &old_s, &new_i, &new_s);
        assert_eq!(plan.reuse, vec![None]);
        assert_eq!(plan.retire, vec![0]);
    }

    #[test]
    fn topology_plan_rebuilds_a_slot_rewritten_in_place() {
        // A size-matched asset hot-reload overwrites the slot's bytes at its
        // existing offsets, so every location field stays equal and only the
        // generation moves. The BLAS traces the old contents and must rebuild.
        let old_i = [5usize];
        let old_s = [sig(5)];
        let new_i = [5usize];
        let mut moved = sig(5);
        moved.generation = 1;
        let plan = plan_topology_refresh(&old_i, &old_s, &new_i, &[moved]);
        assert_eq!(plan.reuse, vec![None]);
        assert_eq!(plan.retire, vec![0]);
    }

    #[test]
    fn geom_sig_tracks_the_draw_object_generation() {
        // A draw object whose slice never moves: only an in-place rewrite of
        // its bytes (the generation bump) may change its signature.
        let mut obj = DrawObject {
            vertex_offset: 256,
            vertex_count: 8,
            index_offset: 12,
            index_count: 6,
            base_vertex: 0,
            geometry_generation: 0,
            shader_bucket: 0,
            model: [[0.0; 4]; 4],
            texture_slot: 0,
            normal_map_slot: 0,
            material: crate::render_types::MaterialUniforms::DEFAULT,
            visible: true,
            resident: true,
            bb_min: [0.0; 3],
            bb_max: [1.0; 3],
            cull_distance: 0.0,
            lod_alternates: Vec::new(),
        };
        let before = GeomSig::of(&obj);
        assert_eq!(before, GeomSig::of(&obj));
        obj.geometry_generation += 1;
        assert_ne!(before, GeomSig::of(&obj));
    }

    #[test]
    fn topology_plan_reuses_across_reorder_by_index() {
        // The participating set is the same but its order changed; each slot still
        // reuses its BLAS by draw index (the reuse points at the old position).
        let old_i = [2usize, 5];
        let old_s = [sig(2), sig(5)];
        let new_i = [5usize, 2];
        let new_s = [sig(5), sig(2)];
        let plan = plan_topology_refresh(&old_i, &old_s, &new_i, &new_s);
        assert_eq!(plan.reuse, vec![Some(1), Some(0)]);
        assert!(plan.retire.is_empty());
    }
}