use crate::gfx::graphics_system::hot_reload_sources::*;
use super::state::*;
pub(crate) fn reload_assets(state: &AssetHotReloadState) {
spawn_asset_decode_worker(state);
spawn_envmap_worker(state);
}
fn spawn_asset_decode_worker(state: &AssetHotReloadState) {
let has_work = !state.map.entries.is_empty()
|| state.color_lut.is_some()
|| !state.meshes.entries.is_empty()
|| !state.skinned_meshes.entries.is_empty();
if !has_work {
return;
}
let mut slot = match state.asset_batch_inflight.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if slot.is_some() {
tracing::info!("asset hot-reload: decode batch skipped; previous worker still running");
return;
}
let textures = state.map.entries.clone();
let color_lut = state.color_lut.clone();
let meshes = state.meshes.entries.clone();
let skinned = state.skinned_meshes.entries.clone();
let totals = (
textures.len(),
meshes.len(),
skinned.len(),
color_lut.is_some(),
);
let (tx, rx) = std::sync::mpsc::channel();
match std::thread::Builder::new()
.name("cn-asset-reload".into())
.spawn(move || {
let batch = crate::jobs::pool()
.install(|| decode_asset_batch(textures, color_lut, meshes, skinned));
let _ = tx.send(batch);
}) {
Ok(_) => {
*slot = Some(rx);
tracing::info!(
"asset hot-reload: decode batch spawned on worker thread \
({} texture(s), {} Mesh(es), {} SkinnedMesh(es), {} ColorLut)",
totals.0,
totals.1,
totals.2,
if totals.3 { 1 } else { 0 }
);
}
Err(e) => {
tracing::error!(
"asset hot-reload: failed to spawn decode worker: {} \
(all assets kept their old payloads)",
e
);
}
}
}
fn spawn_envmap_worker(state: &AssetHotReloadState) {
let Some(env_map) = state.environment_map.as_ref() else {
return;
};
let mut slot = match state.env_map_inflight.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if slot.is_some() {
tracing::info!(
"asset hot-reload: EnvironmentMap reload skipped for '{}'; previous \
convolution still in flight on a worker thread",
env_map.resolved_path
);
return;
}
let (tx, rx) = std::sync::mpsc::channel();
let env_map_copy = env_map.clone();
match std::thread::Builder::new()
.name("cn-envmap-reload".into())
.spawn(move || {
let result = crate::jobs::pool().install(|| {
concinnity_cook::compile::environment_map::decode_source(
&env_map_copy.resolved_path,
env_map_copy.prefilter_face_size,
env_map_copy.irradiance_face_size,
env_map_copy.prefilter_samples,
env_map_copy.prefilter_clamp,
)
});
let _ = tx.send(result);
}) {
Ok(_) => {
*slot = Some(rx);
tracing::info!(
"asset hot-reload: EnvironmentMap convolution for '{}' spawned \
on worker thread (render loop keeps drawing; result will land \
on a later frame via poll_pending_envmap)",
env_map.resolved_path
);
}
Err(e) => {
tracing::error!(
"asset hot-reload: failed to spawn EnvironmentMap worker thread \
for '{}': {} (IBL kept its old payload)",
env_map.resolved_path,
e
);
}
}
}
pub(super) fn decode_asset_batch(
textures: Vec<TextureSourceEntry>,
color_lut: Option<ColorLutSource>,
meshes: Vec<MeshSourceEntry>,
skinned: Vec<SkinnedMeshSourceEntry>,
) -> DecodedAssetBatch {
use rayon::prelude::*;
use std::sync::Mutex;
let mut batch = DecodedAssetBatch::default();
let parsed_glb_cache: Mutex<
std::collections::HashMap<String, concinnity_cook::import::gltf_source::GltfDoc>,
> = Mutex::new(std::collections::HashMap::new());
let assets_dir = crate::authoring::assets_root::assets_dir();
let load_glb = |source: &str| -> Result<concinnity_cook::import::gltf_source::GltfDoc, String> {
if let Some(doc) = parsed_glb_cache.lock().unwrap().get(source) {
return Ok(doc.clone());
}
let doc = concinnity_cook::import::glb::parse_glb(source, assets_dir.as_deref())?;
parsed_glb_cache
.lock()
.unwrap()
.insert(source.to_string(), doc.clone());
Ok(doc)
};
let texture_results: Vec<_> = textures
.par_iter()
.map(|entry| {
let lower = entry.source.to_lowercase();
let decoded = if lower.ends_with(".glb") || lower.ends_with(".gltf") {
match load_glb(&entry.source) {
Ok(doc) => concinnity_cook::compile::texture::decode_glb_image_from_doc(
&doc,
&entry.source,
entry.image_index,
),
Err(e) => Err(e),
}
} else {
concinnity_cook::compile::texture::decode_source(
&entry.source,
entry.image_index,
assets_dir.as_deref(),
)
};
(entry.clone(), decoded)
})
.collect();
for (entry, decoded) in texture_results {
match decoded {
Ok((w, h, px)) => batch.textures.push(DecodedTexture {
slot: entry.slot,
width: w,
height: h,
pixels: px,
source: entry.source,
}),
Err(e) => {
tracing::error!(
"asset hot-reload: failed to decode '{}': {} (slot {} kept its old pixels)",
entry.source,
e,
entry.slot
);
batch.decode_failures += 1;
}
}
}
if let Some(lut) = color_lut {
match concinnity_cook::compile::color_lut::decode_source(&lut.resolved_path) {
Ok((size, data)) => {
batch.color_lut = Some(DecodedColorLut {
size,
data,
source: lut.resolved_path,
});
}
Err(e) => {
tracing::error!(
"asset hot-reload: failed to decode ColorLut '{}': {} (LUT kept its \
old payload)",
lut.resolved_path,
e
);
batch.decode_failures += 1;
}
}
}
for (entry_idx, entry) in meshes.iter().enumerate() {
let doc = match load_glb(&entry.source) {
Ok(d) => d,
Err(e) => {
tracing::error!(
"asset hot-reload: failed to parse Mesh source '{}': {} \
({} draw slot(s) kept their old geometry)",
entry.source,
e,
entry.draw_indices.len()
);
batch.decode_failures += 1;
continue;
}
};
match concinnity_cook::import::mesh_reimport::decode_mesh_from_parsed_glb(
&doc,
&entry.source,
entry.primitive_index,
entry.lod_levels,
&entry.lod_distances,
) {
Ok((verts, idxs, lods)) => batch.meshes.push(DecodedMesh {
entry_idx,
vertices: verts,
indices: idxs,
lod_alternates: lods,
}),
Err(e) => {
tracing::error!(
"asset hot-reload: failed to decode Mesh primitive {} from \
'{}': {} ({} draw slot(s) kept their old geometry)",
entry.primitive_index,
entry.source,
e,
entry.draw_indices.len()
);
batch.decode_failures += 1;
}
}
}
for (entry_idx, entry) in skinned.iter().enumerate() {
let doc = match load_glb(&entry.source) {
Ok(d) => d,
Err(e) => {
tracing::error!(
"asset hot-reload: failed to parse SkinnedMesh source '{}': {} \
(skinned slot {} kept its old geometry)",
entry.source,
e,
entry.skinned_index
);
batch.decode_failures += 1;
continue;
}
};
match concinnity_cook::import::mesh_reimport::decode_skinned_from_parsed_glb(
&doc,
&entry.source,
entry.skin_index,
) {
Ok((verts, idxs, skeleton)) => batch.skinned_meshes.push(DecodedSkinnedMesh {
entry_idx,
vertices: verts,
indices: idxs,
skeleton,
}),
Err(e) => {
tracing::error!(
"asset hot-reload: failed to decode SkinnedMesh '{}': {} \
(skinned slot {} kept its old geometry)",
entry.source,
e,
entry.skinned_index
);
batch.decode_failures += 1;
}
}
}
batch
}
pub(super) fn apply_skinned_layouts_to_entries(
entries: &mut [SkinnedMeshSourceEntry],
layouts: &[crate::gfx::backend::SkinnedSlotLayout],
) {
let layout_by_skinned: std::collections::HashMap<
usize,
&crate::gfx::backend::SkinnedSlotLayout,
> = layouts.iter().map(|l| (l.skinned_index, l)).collect();
for entry in entries.iter_mut() {
if let Some(layout) = layout_by_skinned.get(&entry.skinned_index) {
entry.vertex_base = layout.vertex_base;
entry.vertex_count = layout.vertex_count;
entry.index_count = layout.index_count;
}
}
}
pub(crate) fn poll_pending_assets(
state: &mut AssetHotReloadState,
backend: &mut dyn crate::gfx::backend::RenderBackend,
) -> bool {
let mut slot = match state.asset_batch_inflight.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let rx = match slot.as_ref() {
Some(r) => r,
None => return false,
};
let batch = match rx.try_recv() {
Ok(b) => b,
Err(std::sync::mpsc::TryRecvError::Empty) => return false,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
*slot = None;
tracing::error!(
"asset hot-reload: decode worker disconnected without sending a \
batch (assets kept their old payloads)"
);
return true;
}
};
*slot = None;
drop(slot);
let mut reloaded = 0usize;
let mut failed = batch.decode_failures;
for tex in &batch.textures {
let image = concinnity_core::bake::texture::TextureImage::rgba8(
tex.width,
tex.height,
tex.pixels.clone(),
);
let result = backend.update_texture_slot(tex.slot, &image);
match result {
Ok(()) => reloaded += 1,
Err(e) => {
tracing::error!(
"asset hot-reload: backend rejected slot {} update for '{}': {}",
tex.slot,
tex.source,
e
);
failed += 1;
}
}
}
if let Some(lut) = &batch.color_lut {
match backend.update_color_lut(lut.size, &lut.data) {
Ok(()) => reloaded += 1,
Err(e) => {
tracing::error!(
"asset hot-reload: backend rejected ColorLut update for '{}': {}",
lut.source,
e
);
failed += 1;
}
}
}
let mut rebuild_changes: Vec<crate::gfx::backend::DrawGeometryUpdate> = Vec::new();
let mut rebuild_entry_count = 0usize;
for dm in &batch.meshes {
let entry = match state.meshes.entries.get(dm.entry_idx) {
Some(e) => e,
None => {
tracing::warn!(
"asset hot-reload: decoded Mesh entry_idx {} out of range (source \
map mutated mid-pass?); skipping",
dm.entry_idx
);
failed += 1;
continue;
}
};
let size_changed = entry.draw_indices.iter().any(|&draw_idx| {
let base_changed = match backend.draw_geometry_size(draw_idx) {
Some((v, i)) => v != dm.vertices.len() || i != dm.indices.len(),
None => false,
};
let lod_changed = match backend.draw_lod_index_counts(draw_idx) {
Some(counts) => {
counts.len() != dm.lod_alternates.len()
|| counts
.iter()
.zip(dm.lod_alternates.iter())
.any(|(c, (_, idx))| *c != idx.len())
}
None => false,
};
base_changed || lod_changed
});
if size_changed {
rebuild_entry_count += 1;
for &draw_idx in &entry.draw_indices {
rebuild_changes.push(crate::gfx::backend::DrawGeometryUpdate {
draw_idx,
vertices: dm.vertices.clone(),
indices: dm.indices.clone(),
lod_alternates: dm.lod_alternates.clone(),
});
}
continue;
}
let mut slot_failures = 0usize;
for &draw_idx in &entry.draw_indices {
if let Err(e) = backend.update_mesh_geometry(
draw_idx,
&dm.vertices,
&dm.indices,
&dm.lod_alternates,
) {
tracing::error!(
"asset hot-reload: backend rejected mesh update for draw {} \
(source '{}', primitive {}): {}",
draw_idx,
entry.source,
entry.primitive_index,
e
);
slot_failures += 1;
}
}
if slot_failures == 0 {
reloaded += 1;
} else {
failed += 1;
}
}
if !rebuild_changes.is_empty() {
tracing::info!(
"asset hot-reload: {} Mesh entry/entries had size changes; rebuilding \
shared static-geometry buffers ({} draw slot(s) affected)",
rebuild_entry_count,
rebuild_changes.len()
);
match backend.rebuild_static_geometry(rebuild_changes) {
Ok(()) => reloaded += rebuild_entry_count,
Err(e) => {
tracing::error!(
"asset hot-reload: rebuild_static_geometry failed: {} \
({} Mesh entry/entries kept their old geometry)",
e,
rebuild_entry_count
);
failed += rebuild_entry_count;
}
}
}
let mut skinned_rebuild_changes: Vec<crate::gfx::backend::SkinnedDrawGeometryUpdate> =
Vec::new();
let mut skinned_rebuild_entries: Vec<usize> = Vec::new();
let mut joint_count_changes: Vec<(usize /*entry_idx*/, usize /*new_count*/)> = Vec::new();
for ds in &batch.skinned_meshes {
let entry = match state.skinned_meshes.entries.get(ds.entry_idx) {
Some(e) => e,
None => {
tracing::warn!(
"asset hot-reload: decoded SkinnedMesh entry_idx {} out of range; \
skipping",
ds.entry_idx
);
failed += 1;
continue;
}
};
let new_joint_count = ds.skeleton.len().min(crate::gfx::render_types::MAX_JOINTS);
let joint_count_changed = new_joint_count != entry.joint_count;
let size_changed =
ds.vertices.len() != entry.vertex_count || ds.indices.len() != entry.index_count;
if joint_count_changed {
joint_count_changes.push((ds.entry_idx, new_joint_count));
state.pending_skeleton_updates.push(PendingSkeletonUpdate {
skinned_index: entry.skinned_index,
new_skeleton: crate::components::build_skeleton_from_joint_defs(&ds.skeleton),
});
tracing::info!(
"asset hot-reload: SkinnedMesh '{}' joint count changed ({} → {}), \
resizing joint buffers and refreshing SkeletonPose",
entry.source,
entry.joint_count,
new_joint_count
);
}
if size_changed {
skinned_rebuild_entries.push(ds.entry_idx);
skinned_rebuild_changes.push(crate::gfx::backend::SkinnedDrawGeometryUpdate {
skinned_index: entry.skinned_index,
vertices: ds.vertices.clone(),
indices: ds.indices.clone(),
});
continue;
}
match backend.update_skinned_mesh_geometry(
entry.skinned_index,
entry.vertex_base,
&ds.vertices,
&ds.indices,
) {
Ok(()) => reloaded += 1,
Err(e) => {
tracing::error!(
"asset hot-reload: backend rejected SkinnedMesh update for \
skinned slot {} (source '{}'): {}",
entry.skinned_index,
entry.source,
e
);
failed += 1;
}
}
}
if !skinned_rebuild_changes.is_empty() {
tracing::info!(
"asset hot-reload: {} SkinnedMesh entry/entries had size changes; \
rebuilding shared skinned vertex / index buffers",
skinned_rebuild_changes.len()
);
match backend.rebuild_skinned_geometry(skinned_rebuild_changes) {
Ok(new_layouts) => {
apply_skinned_layouts_to_entries(&mut state.skinned_meshes.entries, &new_layouts);
reloaded += skinned_rebuild_entries.len();
}
Err(e) => {
tracing::error!(
"asset hot-reload: rebuild_skinned_geometry failed: {} \
({} SkinnedMesh entry/entries kept their old geometry)",
e,
skinned_rebuild_entries.len()
);
failed += skinned_rebuild_entries.len();
let failed_skinned: std::collections::HashSet<usize> = skinned_rebuild_entries
.iter()
.filter_map(|&i| state.skinned_meshes.entries.get(i))
.map(|e| e.skinned_index)
.collect();
state
.pending_skeleton_updates
.retain(|u| !failed_skinned.contains(&u.skinned_index));
joint_count_changes.retain(|(entry_idx, _)| {
state
.skinned_meshes
.entries
.get(*entry_idx)
.map(|e| !failed_skinned.contains(&e.skinned_index))
.unwrap_or(true)
});
}
}
}
for (entry_idx, new_count) in &joint_count_changes {
let entry = match state.skinned_meshes.entries.get_mut(*entry_idx) {
Some(e) => e,
None => continue,
};
if let Err(e) = backend.update_skinned_skeleton(entry.skinned_index, *new_count) {
tracing::error!(
"asset hot-reload: backend rejected joint-count update for skinned slot {} \
(source '{}'): {} (joint_count stays at {})",
entry.skinned_index,
entry.source,
e,
entry.joint_count
);
let stale_skinned_index = entry.skinned_index;
state
.pending_skeleton_updates
.retain(|u| u.skinned_index != stale_skinned_index);
failed += 1;
} else {
entry.joint_count = *new_count;
}
}
tracing::info!(
"asset hot-reload: applied off-thread batch, reloaded {} asset(s) ({} failed)",
reloaded,
failed
);
true
}
pub(crate) fn poll_pending_envmap(
state: &AssetHotReloadState,
backend: &mut dyn crate::gfx::backend::RenderBackend,
) -> bool {
let mut slot = match state.env_map_inflight.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let rx = match slot.as_ref() {
Some(r) => r,
None => return false,
};
match rx.try_recv() {
Ok(Ok(payload)) => {
*slot = None;
drop(slot);
match backend.update_environment_map(&payload) {
Ok(()) => {
tracing::info!(
"asset hot-reload: EnvironmentMap convolution completed \
off-thread and applied to IBL cubes"
);
}
Err(e) => {
tracing::error!(
"asset hot-reload: backend rejected off-thread EnvironmentMap \
payload: {} (IBL kept its old payload)",
e
);
}
}
true
}
Ok(Err(msg)) => {
*slot = None;
tracing::error!(
"asset hot-reload: off-thread EnvironmentMap convolution failed: {} \
(IBL kept its old payload)",
msg
);
true
}
Err(std::sync::mpsc::TryRecvError::Empty) => false,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
*slot = None;
tracing::error!(
"asset hot-reload: EnvironmentMap worker thread disconnected without \
sending a result (IBL kept its old payload)"
);
true
}
}
}