use concinnity_core::components::Identity;
use concinnity_core::components::ProceduralMesh;
use concinnity_core::ecs::PipelineContext;
use concinnity_core::ecs::asset_id::AssetId;
use concinnity_core::gfx::render_types::{DrawIndex, SkinnedIndex};
use concinnity_host::thread::asset_id;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use super::parked::{ShaderOverrides, TextureNameSlots};
use super::sdf_field_sources::SdfFieldMap;
use super::shader_sources::ShaderSourceMap;
use crate::gfx::draw_list::MeshSourceMeta;
pub(super) fn watch_dirs_of<'a>(paths: impl Iterator<Item = &'a str>) -> Vec<PathBuf> {
let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
for path in paths {
if let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
dirs.insert(parent.to_path_buf());
}
}
dirs.into_iter().collect()
}
#[derive(Debug, Clone)]
pub struct TextureSourceEntry {
pub source: String,
pub image_index: u32,
pub slot: usize,
}
#[derive(Debug, Clone)]
pub struct ColorLutSource {
pub resolved_path: String,
}
#[derive(Debug, Clone)]
pub struct MeshSourceEntry {
pub source: String,
pub primitive_index: u32,
pub lod_levels: u32,
pub lod_distances: Vec<f32>,
pub draw_indices: Vec<DrawIndex>,
}
#[derive(Debug, Clone, Default)]
pub struct MeshSourceMap {
pub entries: Vec<MeshSourceEntry>,
}
impl MeshSourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn watch_dirs(&self) -> Vec<PathBuf> {
watch_dirs_of(self.entries.iter().map(|e| e.source.as_str()))
}
}
#[derive(Debug, Clone)]
pub struct ProceduralMeshSourceEntry {
pub name: String,
pub args: ProceduralMesh,
pub draw_indices: Vec<DrawIndex>,
}
#[derive(Debug, Clone, Default)]
pub struct ProceduralMeshSourceMap {
pub entries: Vec<ProceduralMeshSourceEntry>,
}
impl ProceduralMeshSourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
}
#[derive(Debug, Clone)]
pub struct SkinnedMeshSourceEntry {
pub source: String,
pub skin_index: u32,
pub skinned_index: SkinnedIndex,
pub vertex_base: u32,
pub vertex_count: usize,
pub index_count: usize,
pub joint_count: usize,
}
#[derive(Debug, Clone, Default)]
pub struct SkinnedMeshSourceMap {
pub entries: Vec<SkinnedMeshSourceEntry>,
}
impl SkinnedMeshSourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn watch_dirs(&self) -> Vec<PathBuf> {
watch_dirs_of(self.entries.iter().map(|e| e.source.as_str()))
}
}
#[derive(Debug, Clone)]
pub struct EnvironmentMapSource {
pub resolved_path: String,
pub prefilter_face_size: u32,
pub irradiance_face_size: u32,
pub prefilter_samples: u32,
pub prefilter_clamp: f32,
}
#[derive(Debug, Clone, Default)]
pub struct TextureSourceMap {
pub entries: Vec<TextureSourceEntry>,
}
impl TextureSourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn push_texture(&mut self, source: String, image_index: u32, slot: usize) {
self.entries.push(TextureSourceEntry {
source,
image_index,
slot,
});
}
pub fn watch_dirs(&self) -> Vec<PathBuf> {
watch_dirs_of(self.entries.iter().map(|e| e.source.as_str()))
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
}
#[derive(Default)]
pub struct HotReloadSources {
pub map: TextureSourceMap,
pub color_lut: Option<ColorLutSource>,
pub environment_map: Option<EnvironmentMapSource>,
pub meshes: MeshSourceMap,
pub skinned_meshes: SkinnedMeshSourceMap,
pub procedural_meshes: ProceduralMeshSourceMap,
pub shaders: ShaderSourceMap,
pub shader_overrides: ShaderOverrides,
pub sdf_fields: SdfFieldMap,
}
impl HotReloadSources {
pub fn is_empty(&self) -> bool {
self.map.is_empty()
&& self.color_lut.is_none()
&& self.environment_map.is_none()
&& self.meshes.is_empty()
&& self.skinned_meshes.is_empty()
&& self.procedural_meshes.is_empty()
&& self.shaders.is_empty()
&& self.sdf_fields.is_empty()
}
}
pub(super) fn procedural_mesh_snapshot(
ctx: &PipelineContext,
) -> HashMap<AssetId, (String, ProceduralMesh)> {
ctx.join2::<ProceduralMesh, Identity>()
.filter_map(|(_, pm, identity)| {
let id = identity.id();
Some((id, (asset_id::name_of(id)?, pm.clone())))
})
.collect()
}
pub(super) fn mesh_source_map(
sources: &HashMap<usize, MeshSourceMeta>,
mesh_handle_to_draws: &HashMap<usize, Vec<DrawIndex>>,
) -> MeshSourceMap {
let entries = sources
.iter()
.filter_map(|(handle, meta)| {
let draws = mesh_handle_to_draws.get(handle).filter(|d| !d.is_empty())?;
Some(MeshSourceEntry {
source: meta.source.clone(),
primitive_index: meta.primitive_index,
lod_levels: meta.lod_levels,
lod_distances: meta.lod_distances.clone(),
draw_indices: draws.clone(),
})
})
.collect();
MeshSourceMap { entries }
}
pub(super) fn procedural_mesh_source_map(
snapshot: &HashMap<AssetId, (String, ProceduralMesh)>,
component_handles: &HashMap<AssetId, usize>,
mesh_handle_to_draws: &HashMap<usize, Vec<DrawIndex>>,
) -> ProceduralMeshSourceMap {
let entries = snapshot
.iter()
.filter_map(|(asset_id, (name, args))| {
let handle = component_handles.get(asset_id)?;
let draws = mesh_handle_to_draws.get(handle).filter(|d| !d.is_empty())?;
Some(ProceduralMeshSourceEntry {
name: name.clone(),
args: args.clone(),
draw_indices: draws.clone(),
})
})
.collect();
ProceduralMeshSourceMap { entries }
}
pub(super) fn capture_hot_reload_sources(
sources: HotReloadSources,
texture_name_to_slot: HashMap<AssetId, usize>,
) -> (HotReloadSources, TextureNameSlots) {
tracing::info!(
"asset hot-reload: captured {} file-backed texture source(s), {} \
ColorLut source(s), {} EnvironmentMap source(s), {} Mesh \
source(s), {} SkinnedMesh source(s), {} ProceduralMesh source(s), \
{} Shader source(s), and {} SdfVolume field(s)",
sources.map.len(),
usize::from(sources.color_lut.is_some()),
usize::from(sources.environment_map.is_some()),
sources.meshes.len(),
sources.skinned_meshes.len(),
sources.procedural_meshes.len(),
sources.shaders.len(),
sources.sdf_fields.len(),
);
(sources, TextureNameSlots(texture_name_to_slot))
}
#[cfg(test)]
mod tests {
use super::*;
fn mesh_entry(source: &str) -> MeshSourceEntry {
MeshSourceEntry {
source: source.to_string(),
primitive_index: 0,
lod_levels: 1,
lod_distances: Vec::new(),
draw_indices: vec![DrawIndex(0)],
}
}
fn skinned_entry(source: &str) -> SkinnedMeshSourceEntry {
SkinnedMeshSourceEntry {
source: source.to_string(),
skin_index: 0,
skinned_index: SkinnedIndex(0),
vertex_base: 0,
vertex_count: 3,
index_count: 3,
joint_count: 1,
}
}
fn dirs(paths: &[PathBuf]) -> Vec<String> {
paths.iter().map(|p| p.display().to_string()).collect()
}
#[test]
fn texture_map_starts_empty_and_counts_pushed_entries() {
let mut map = TextureSourceMap::new();
assert!(map.is_empty());
assert_eq!(map.len(), 0);
map.push_texture("assets/wall.png".to_string(), 0, 4);
map.push_texture("assets/scene.glb".to_string(), 2, 7);
assert!(!map.is_empty());
assert_eq!(map.len(), 2);
assert_eq!(map.entries[0].slot, 4);
assert_eq!(map.entries[0].image_index, 0);
assert_eq!(map.entries[1].slot, 7);
assert_eq!(map.entries[1].image_index, 2);
}
#[test]
fn texture_watch_dirs_dedups_shared_parents() {
let mut map = TextureSourceMap::new();
map.push_texture("assets/textures/wall.png".to_string(), 0, 0);
map.push_texture("assets/textures/floor.png".to_string(), 0, 1);
map.push_texture("assets/models/scene.glb".to_string(), 0, 2);
assert_eq!(
dirs(&map.watch_dirs()),
["assets/models", "assets/textures"],
"one entry per unique parent, sorted"
);
}
#[test]
fn texture_watch_dirs_skips_bare_filenames() {
let mut map = TextureSourceMap::new();
map.push_texture("wall.png".to_string(), 0, 0);
assert!(map.watch_dirs().is_empty());
map.push_texture("assets/floor.png".to_string(), 0, 1);
assert_eq!(dirs(&map.watch_dirs()), ["assets"]);
}
#[test]
fn mesh_map_watches_parents_and_keeps_every_draw_slot() {
let mut map = MeshSourceMap::new();
assert!(map.is_empty());
let mut shared = mesh_entry("assets/models/prop.glb");
shared.draw_indices = vec![DrawIndex(3), DrawIndex(9), DrawIndex(12)];
map.entries.push(shared);
map.entries.push(mesh_entry("assets/models/tree.glb"));
map.entries.push(mesh_entry("bare.glb"));
assert_eq!(map.len(), 3);
assert_eq!(
dirs(&map.watch_dirs()),
["assets/models"],
"the two rooted entries share one dir, the bare one is skipped"
);
assert_eq!(
map.entries[0].draw_indices,
vec![DrawIndex(3), DrawIndex(9), DrawIndex(12)],
"a reload has to rewrite every slot carrying this mesh"
);
}
#[test]
fn skinned_mesh_map_watches_parents() {
let mut map = SkinnedMeshSourceMap::new();
assert!(map.is_empty());
map.entries.push(skinned_entry("assets/chars/fox.glb"));
map.entries.push(skinned_entry("assets/chars/wolf.glb"));
map.entries.push(skinned_entry("fox.glb"));
assert_eq!(map.len(), 3);
assert_eq!(dirs(&map.watch_dirs()), ["assets/chars"]);
}
#[test]
fn procedural_mesh_map_counts_entries() {
let mut map = ProceduralMeshSourceMap::new();
assert!(map.is_empty());
assert_eq!(map.len(), 0);
map.entries.push(ProceduralMeshSourceEntry {
name: "ground".to_string(),
args: Default::default(),
draw_indices: vec![DrawIndex(0), DrawIndex(1)],
});
assert!(!map.is_empty());
assert_eq!(map.len(), 1);
}
#[test]
fn bundle_defaults_to_nothing_captured() {
let sources = HotReloadSources::default();
assert!(sources.map.is_empty());
assert!(sources.meshes.is_empty());
assert!(sources.skinned_meshes.is_empty());
assert!(sources.procedural_meshes.is_empty());
assert!(sources.shaders.is_empty());
assert!(sources.color_lut.is_none());
assert!(sources.environment_map.is_none());
assert!(sources.is_empty());
}
#[test]
fn bundle_with_any_single_capture_is_not_empty() {
let populated: [fn(&mut HotReloadSources); 7] = [
|s| s.map.push_texture("assets/wall.png".to_string(), 0, 0),
|s| {
s.color_lut = Some(ColorLutSource {
resolved_path: "assets/grade.cube".to_string(),
})
},
|s| {
s.environment_map = Some(EnvironmentMapSource {
resolved_path: "assets/sky.hdr".to_string(),
prefilter_face_size: 64,
irradiance_face_size: 16,
prefilter_samples: 32,
prefilter_clamp: 10.0,
})
},
|s| s.meshes.entries.push(mesh_entry("assets/prop.glb")),
|s| {
s.skinned_meshes
.entries
.push(skinned_entry("assets/fox.glb"))
},
|s| {
s.procedural_meshes.entries.push(ProceduralMeshSourceEntry {
name: "ground".to_string(),
args: Default::default(),
draw_indices: vec![DrawIndex(0)],
})
},
|s| {
s.shaders
.entries
.push(super::super::shader_sources::ShaderSourceEntry {
id: AssetId(1),
name: "scene".to_string(),
bucket: 0,
files: Vec::new(),
})
},
];
for (i, populate) in populated.iter().enumerate() {
let mut sources = HotReloadSources::default();
populate(&mut sources);
assert!(!sources.is_empty(), "field {i} alone is a capture");
}
}
#[test]
fn capturing_nothing_parks_an_empty_catalog() {
let names = HashMap::from([(AssetId(7), 0)]);
let (sources, slots) = capture_hot_reload_sources(HotReloadSources::default(), names);
assert!(sources.is_empty());
assert_eq!(slots.0, HashMap::from([(AssetId(7), 0)]));
}
fn meta(source: &str) -> MeshSourceMeta {
MeshSourceMeta {
source: source.to_string(),
primitive_index: 1,
lod_levels: 2,
lod_distances: vec![10.0],
}
}
#[test]
fn mesh_source_map_omits_handles_without_draws() {
let sources = HashMap::from([
(0, meta("assets/drawn.glb")),
(1, meta("assets/unreferenced.glb")),
(2, meta("assets/emptied.glb")),
]);
let draws = HashMap::from([(0, vec![DrawIndex(3), DrawIndex(4)]), (2, Vec::new())]);
let map = mesh_source_map(&sources, &draws);
assert_eq!(map.len(), 1);
let entry = &map.entries[0];
assert_eq!(entry.source, "assets/drawn.glb");
assert_eq!(entry.draw_indices, vec![DrawIndex(3), DrawIndex(4)]);
assert_eq!((entry.primitive_index, entry.lod_levels), (1, 2));
}
#[test]
fn procedural_mesh_source_map_omits_meshes_without_draws() {
let args = ProceduralMesh::default;
let snapshot = HashMap::from([
(AssetId(1), ("drawn".to_string(), args())),
(AssetId(2), ("no_handle".to_string(), args())),
(AssetId(3), ("unreferenced".to_string(), args())),
(AssetId(4), ("emptied".to_string(), args())),
]);
let handles = HashMap::from([(AssetId(1), 0), (AssetId(3), 1), (AssetId(4), 2)]);
let draws = HashMap::from([(0, vec![DrawIndex(5)]), (2, Vec::new())]);
let map = procedural_mesh_source_map(&snapshot, &handles, &draws);
assert_eq!(map.len(), 1);
assert_eq!(map.entries[0].name, "drawn");
assert_eq!(map.entries[0].draw_indices, vec![DrawIndex(5)]);
}
}