Skip to main content

gizmo_renderer/
async_assets.rs

1//! Background-thread decoding for textures, OBJ, and GLTF **import** (disk + parse).
2//! GPU upload and [`AssetManager`](crate::asset::AssetManager) updates must run on the main thread
3//! — call [`AsyncAssetLoader::drain_completed`] each frame and then upload via `AssetManager`.
4
5use crate::asset::{decode_obj_vertices_for_async, decode_rgba_image_file};
6use std::collections::{HashMap, HashSet};
7use std::sync::mpsc::{self, Receiver, Sender, SyncSender};
8use std::sync::{Arc, Mutex};
9use std::thread;
10
11#[derive(Debug)]
12pub struct TextureReloadCompletion {
13    pub cache_key: String,
14    pub rgba: Vec<u8>,
15    pub width: u32,
16    pub height: u32,
17    pub entity_ids: Vec<usize>,
18}
19
20#[derive(Debug)]
21pub struct ObjLoadCompletion {
22    pub path: String,
23    pub vertices: Vec<crate::gpu_types::Vertex>,
24    pub aabb: gizmo_math::Aabb,
25    pub handle_ids: Vec<usize>,
26}
27
28/// Successful GLTF parse on the worker; GPU upload via [`AssetManager::load_gltf_from_import`](crate::asset::AssetManager::load_gltf_from_import).
29#[derive(Debug)]
30pub struct GltfImportCompletion {
31    pub path: String,
32    pub document: gltf::Document,
33    pub buffers: Vec<gltf::buffer::Data>,
34    pub images: Vec<gltf::image::Data>,
35}
36
37#[derive(Debug)]
38pub struct GltfImportError {
39    pub path: String,
40    pub message: String,
41}
42
43#[derive(Debug)]
44pub struct CompletedAsyncLoads {
45    pub textures: Vec<TextureReloadCompletion>,
46    pub objs: Vec<ObjLoadCompletion>,
47    pub gltfs: Vec<GltfImportCompletion>,
48    pub gltf_errors: Vec<GltfImportError>,
49}
50
51enum Job {
52    Texture { request_path: String },
53    Obj { path: String },
54    Gltf { path: String },
55}
56
57enum WorkerMsg {
58    Texture {
59        request_path: String,
60        cache_key: String,
61        result: Result<(Vec<u8>, u32, u32), String>,
62    },
63    Obj {
64        path: String,
65        result: Result<(Vec<crate::gpu_types::Vertex>, gizmo_math::Aabb), String>,
66    },
67    Gltf {
68        path: String,
69        result: Result<
70            (
71                gltf::Document,
72                Vec<gltf::buffer::Data>,
73                Vec<gltf::image::Data>,
74            ),
75            String,
76        >,
77    },
78}
79
80struct LoaderShared {
81    job_tx: SyncSender<Job>,
82    result_rx: Receiver<WorkerMsg>,
83    /// Original request path (as passed to `request_texture_reload`) → entities
84    texture_waiters: HashMap<String, Vec<usize>>,
85    obj_waiters: HashMap<String, Vec<usize>>,
86    texture_inflight: HashSet<String>,
87    obj_inflight: HashSet<String>,
88    gltf_inflight: HashSet<String>,
89}
90
91/// Thread-safe loader; safe to store as an ECS resource (`Send` + `Sync`).
92pub struct AsyncAssetLoader {
93    shared: Arc<Mutex<LoaderShared>>,
94    _worker: Option<thread::JoinHandle<()>>,
95    #[allow(dead_code)]
96    result_tx: Sender<WorkerMsg>,
97}
98
99impl AsyncAssetLoader {
100    pub fn new() -> Self {
101        let (job_tx, job_rx) = mpsc::sync_channel::<Job>(64);
102        let (result_tx, result_rx) = mpsc::channel::<WorkerMsg>();
103
104        let worker_job_rx = job_rx;
105        let worker_result_tx = result_tx.clone();
106        #[cfg(not(target_arch = "wasm32"))]
107        let _worker = Some(
108            thread::Builder::new()
109                .name("gizmo-async-assets".into())
110                .spawn(move || {
111                    for job in worker_job_rx {
112                        match job {
113                            Job::Texture { request_path } => {
114                                let cache_key = std::path::Path::new(&request_path)
115                                    .canonicalize()
116                                    .map(|p| p.to_string_lossy().into_owned())
117                                    .unwrap_or_else(|_| request_path.clone());
118                                let result = decode_rgba_image_file(&request_path);
119                                let _ = worker_result_tx.send(WorkerMsg::Texture {
120                                    request_path,
121                                    cache_key,
122                                    result,
123                                });
124                            }
125                            Job::Obj { path } => {
126                                let result = decode_obj_vertices_for_async(&path);
127                                let _ = worker_result_tx.send(WorkerMsg::Obj { path, result });
128                            }
129                            Job::Gltf { path } => {
130                                let result = gltf::import(&path).map_err(|e| e.to_string());
131                                let _ = worker_result_tx.send(WorkerMsg::Gltf { path, result });
132                            }
133                        }
134                    }
135                })
136                .expect("spawn async asset worker"),
137        );
138
139        #[cfg(target_arch = "wasm32")]
140        let _worker = None;
141
142        Self {
143            shared: Arc::new(Mutex::new(LoaderShared {
144                job_tx,
145                result_rx,
146                texture_waiters: HashMap::new(),
147                obj_waiters: HashMap::new(),
148                texture_inflight: HashSet::new(),
149                obj_inflight: HashSet::new(),
150                gltf_inflight: HashSet::new(),
151            })),
152            _worker,
153            result_tx,
154        }
155    }
156
157    /// Queue a texture file decode; when done, [`drain_completed`] yields a row with `entity_ids`.
158    /// Duplicate `request_path` while in-flight only adds more waiters (one disk read).
159    pub fn request_texture_reload(&self, request_path: String, handle_id: usize) {
160        let mut g = self.shared.lock().expect("async asset mutex");
161        g.texture_waiters
162            .entry(request_path.clone())
163            .or_default()
164            .push(handle_id);
165        if g.texture_inflight.insert(request_path.clone()) {
166            #[cfg(not(target_arch = "wasm32"))]
167            {
168                let _ = g.job_tx.send(Job::Texture { request_path });
169            }
170            #[cfg(target_arch = "wasm32")]
171            {
172                let result_tx = self.result_tx.clone();
173                let path = request_path.clone();
174                wasm_bindgen_futures::spawn_local(async move {
175                    let result = fetch_and_decode_texture_wasm(&path).await;
176                    let cache_key = path.clone();
177                    let _ = result_tx.send(WorkerMsg::Texture {
178                        request_path: path,
179                        cache_key,
180                        result,
181                    });
182                });
183            }
184        }
185    }
186
187    /// Queue an OBJ load (returns `ObjLoadCompletion` eventually).
188    pub fn request_obj_load(&self, path: String, handle_id: usize) {
189        let mut g = self.shared.lock().expect("async asset mutex");
190        g.obj_waiters
191            .entry(path.clone())
192            .or_default()
193            .push(handle_id);
194        if g.obj_inflight.insert(path.clone()) {
195            #[cfg(not(target_arch = "wasm32"))]
196            {
197                let _ = g.job_tx.send(Job::Obj { path });
198            }
199            #[cfg(target_arch = "wasm32")]
200            {
201                let result_tx = self.result_tx.clone();
202                let path_clone = path.clone();
203                wasm_bindgen_futures::spawn_local(async move {
204                    let result = fetch_and_decode_obj_wasm(&path_clone).await;
205                    let _ = result_tx.send(WorkerMsg::Obj {
206                        path: path_clone,
207                        result,
208                    });
209                });
210            }
211        }
212    }
213
214    /// Run `gltf::import` off the main thread; upload with `AssetManager::load_gltf_from_import`.
215    pub fn request_gltf_import(&self, path: String) -> bool {
216        tracing::info!(">>> request_gltf_import çağrıldı: {}", path);
217        let mut g = self.shared.lock().expect("async asset mutex");
218        if g.gltf_inflight.contains(&path) {
219            tracing::info!(">>> request_gltf_import: Model zaten yükleniyor!");
220            return false;
221        }
222        g.gltf_inflight.insert(path.clone());
223
224        #[cfg(not(target_arch = "wasm32"))]
225        {
226            let ok = g.job_tx.send(Job::Gltf { path }).is_ok();
227            tracing::info!(">>> request_gltf_import: İşlem gönderildi mi? {}", ok);
228            ok
229        }
230        #[cfg(target_arch = "wasm32")]
231        {
232            let result_tx = self.result_tx.clone();
233            let path_clone = path.clone();
234            wasm_bindgen_futures::spawn_local(async move {
235                let result = fetch_and_parse_gltf_wasm(&path_clone).await;
236                let _ = result_tx.send(WorkerMsg::Gltf {
237                    path: path_clone,
238                    result,
239                });
240            });
241            true
242        }
243    }
244
245    /// Non-blocking: collect all finished jobs since the last call.
246    pub fn drain_completed(&self) -> CompletedAsyncLoads {
247        let mut out = CompletedAsyncLoads {
248            textures: Vec::new(),
249            objs: Vec::new(),
250            gltfs: Vec::new(),
251            gltf_errors: Vec::new(),
252        };
253
254        let mut g = self.shared.lock().expect("async asset mutex");
255        while let Ok(msg) = g.result_rx.try_recv() {
256            match msg {
257                WorkerMsg::Texture {
258                    request_path,
259                    cache_key,
260                    result,
261                } => {
262                    g.texture_inflight.remove(&request_path);
263                    let entity_ids = g.texture_waiters.remove(&request_path).unwrap_or_default();
264                    if entity_ids.is_empty() {
265                        continue;
266                    }
267                    match result {
268                        Ok((rgba, width, height)) => {
269                            out.textures.push(TextureReloadCompletion {
270                                cache_key,
271                                rgba,
272                                width,
273                                height,
274                                entity_ids,
275                            });
276                        }
277                        Err(e) => {
278                            tracing::error!(
279                                "[AsyncAssetLoader] Texture decode failed ({request_path}): {e}"
280                            );
281                        }
282                    }
283                }
284                WorkerMsg::Obj { path, result } => {
285                    g.obj_inflight.remove(&path);
286                    let handle_ids = g.obj_waiters.remove(&path).unwrap_or_default();
287                    if handle_ids.is_empty() {
288                        continue;
289                    }
290                    match result {
291                        Ok((vertices, aabb)) => {
292                            out.objs.push(ObjLoadCompletion {
293                                path,
294                                vertices,
295                                aabb,
296                                handle_ids,
297                            });
298                        }
299                        Err(e) => {
300                            tracing::error!("[AsyncAssetLoader] OBJ decode failed ({path}): {e}");
301                        }
302                    }
303                }
304                WorkerMsg::Gltf { path, result } => {
305                    g.gltf_inflight.remove(&path);
306                    match result {
307                        Ok((document, buffers, images)) => {
308                            out.gltfs.push(GltfImportCompletion {
309                                path,
310                                document,
311                                buffers,
312                                images,
313                            });
314                        }
315                        Err(message) => {
316                            out.gltf_errors.push(GltfImportError { path, message });
317                        }
318                    }
319                }
320            }
321        }
322
323        out
324    }
325}
326
327impl Default for AsyncAssetLoader {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333// ── WASM Fetch & Parse Helpers ──────────────────────────────────────────────
334
335#[cfg(target_arch = "wasm32")]
336async fn native_fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
337    use wasm_bindgen::JsCast;
338    use wasm_bindgen_futures::JsFuture;
339
340    let window = web_sys::window().ok_or("No global window found")?;
341    let resp_value = JsFuture::from(window.fetch_with_str(url))
342        .await
343        .map_err(|e| format!("Fetch failed for {url}: {e:?}"))?;
344    let resp: web_sys::Response = resp_value.dyn_into().map_err(|_| "Failed to cast to Response")?;
345
346    if !resp.ok() {
347        return Err(format!("HTTP error status for {url}: {}", resp.status()));
348    }
349
350    let array_buffer_value = JsFuture::from(resp.array_buffer().map_err(|e| format!("Failed to get array buffer: {e:?}"))?)
351        .await
352        .map_err(|e| format!("Failed to resolve array buffer: {e:?}"))?;
353    let array_buffer = js_sys::ArrayBuffer::from(array_buffer_value);
354    let uint8_array = js_sys::Uint8Array::new(&array_buffer);
355    let mut bytes = vec![0; uint8_array.length() as usize];
356    uint8_array.copy_to(&mut bytes);
357    Ok(bytes)
358}
359
360#[cfg(target_arch = "wasm32")]
361async fn fetch_and_decode_texture_wasm(path: &str) -> Result<(Vec<u8>, u32, u32), String> {
362    let bytes = native_fetch_bytes(path).await?;
363
364    let img = image::load_from_memory(&bytes)
365        .map_err(|e| format!("Cannot read texture ({path}) from memory: {e}"))?
366        .to_rgba8();
367    let (w, h) = img.dimensions();
368    Ok((img.into_raw(), w, h))
369}
370
371#[cfg(target_arch = "wasm32")]
372async fn fetch_and_parse_gltf_wasm(
373    path: &str,
374) -> Result<
375    (
376        gltf::Document,
377        Vec<gltf::buffer::Data>,
378        Vec<gltf::image::Data>,
379    ),
380    String,
381> {
382    let bytes = native_fetch_bytes(path).await?;
383
384    gltf::import_slice(&bytes)
385        .map_err(|e| format!("glTF parse slice failed ({path}): {e}"))
386}
387
388#[cfg(target_arch = "wasm32")]
389async fn fetch_and_decode_obj_wasm(
390    path: &str,
391) -> Result<(Vec<crate::gpu_types::Vertex>, gizmo_math::Aabb), String> {
392    let bytes = native_fetch_bytes(path).await?;
393
394    let mut reader = std::io::Cursor::new(bytes);
395    let (models, _) = tobj::load_obj_buf(
396        &mut reader,
397        &tobj::LoadOptions {
398            single_index: true,
399            triangulate: true,
400            ignore_points: true,
401            ignore_lines: true,
402        },
403        |_| Err(tobj::LoadError::OpenFileFailed),
404    )
405    .map_err(|e| format!("OBJ load from buffer failed ({path}): {e}"))?;
406
407    if models.is_empty() {
408        return Err(format!("OBJ file contains no models: {path}"));
409    }
410
411    let mut aabb = gizmo_math::Aabb::empty();
412    let mut vertices = Vec::new();
413
414    for model in &models {
415        let m = &model.mesh;
416        let has_normals = !m.normals.is_empty();
417        let has_texcoords = !m.texcoords.is_empty();
418
419        for &raw_idx in &m.indices {
420            let idx = raw_idx as usize;
421            let pos_base = idx * 3;
422            if pos_base + 2 >= m.positions.len() {
423                return Err(format!("OBJ ({path}): position index out of range"));
424            }
425            let position = [
426                m.positions[pos_base],
427                m.positions[pos_base + 1],
428                m.positions[pos_base + 2],
429            ];
430            aabb.extend(gizmo_math::Vec3::new(position[0], position[1], position[2]));
431
432            let normal = if has_normals {
433                let n_base = idx * 3;
434                [
435                    m.normals[n_base],
436                    m.normals[n_base + 1],
437                    m.normals[n_base + 2],
438                ]
439            } else {
440                [0.0, 1.0, 0.0]
441            };
442
443            let tex_coords = if has_texcoords {
444                let uv_base = idx * 2;
445                [m.texcoords[uv_base], 1.0 - m.texcoords[uv_base + 1]]
446            } else {
447                [0.0, 0.0]
448            };
449
450            vertices.push(crate::renderer::Vertex {
451                position,
452                normal,
453                tex_coords,
454                color: [1.0, 1.0, 1.0],
455                joint_indices: [0; 4],
456                joint_weights: [0.0; 4],
457            });
458        }
459    }
460
461    Ok((vertices, aabb))
462}