Skip to main content

gizmo_renderer/asset/
mod.rs

1use crate::components::Mesh;
2use crate::renderer::Vertex;
3use gizmo_math::Vec3;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use uuid::Uuid;
7use wgpu::util::DeviceExt;
8
9pub mod loaders;
10pub mod primitives;
11pub mod texture;
12
13pub use loaders::GltfNodeData;
14
15// ============================================================================
16//  Asset metadata
17// ============================================================================
18
19/// Persisted alongside every asset file as `<filename>.meta`.
20///
21/// Stable UUIDs let editor tools and serialised scenes reference assets by
22/// identity rather than by path, surviving renames and moves.
23#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
24pub struct AssetMeta {
25    pub uuid: Uuid,
26}
27
28// ============================================================================
29//  Free decode helpers (CPU-only, safe to call from worker threads)
30// ============================================================================
31
32/// Decode an image file to RGBA8 on a background thread (no GPU access).
33pub fn decode_rgba_image_file(path: &str) -> Result<(Vec<u8>, u32, u32), String> {
34    let img = image::open(path)
35        .map_err(|e| format!("Cannot read texture ({path}): {e}"))?
36        .to_rgba8();
37    let (w, h) = img.dimensions();
38    Ok((img.into_raw(), w, h))
39}
40
41/// Decode an OBJ file to a flat vertex buffer + AABB without touching the GPU.
42///
43/// Intended for use with [`crate::async_assets::AsyncAssetLoader`]: call this
44/// on a worker thread, then hand the result to
45/// [`AssetManager::install_obj_mesh`] on the main thread.
46pub fn decode_obj_vertices_for_async(
47    file_path: &str,
48) -> Result<(Vec<Vertex>, gizmo_math::Aabb), String> {
49    let (models, _) = tobj::load_obj(
50        file_path,
51        &tobj::LoadOptions {
52            single_index: true,
53            triangulate: true,
54            ignore_points: true,
55            ignore_lines: true,
56        },
57    )
58    .map_err(|e| format!("OBJ load failed ({file_path}): {e}"))?;
59
60    if models.is_empty() {
61        return Err(format!("OBJ file contains no models: {file_path}"));
62    }
63
64    let mut aabb = gizmo_math::Aabb::empty();
65    let mut vertices = Vec::new();
66
67    for model in &models {
68        let m = &model.mesh;
69        let has_normals = !m.normals.is_empty();
70        let has_texcoords = !m.texcoords.is_empty();
71        let model_start = vertices.len(); // first vertex of this model
72
73        for &raw_idx in &m.indices {
74            let idx = raw_idx as usize;
75
76            // ── Position ─────────────────────────────────────────────────
77            let pos_base = idx * 3;
78            if pos_base + 2 >= m.positions.len() {
79                return Err(format!(
80                    "OBJ ({file_path}): position index {idx} out of range \
81                     (positions.len={})",
82                    m.positions.len()
83                ));
84            }
85            let position = [
86                m.positions[pos_base],
87                m.positions[pos_base + 1],
88                m.positions[pos_base + 2],
89            ];
90            aabb.extend(Vec3::new(position[0], position[1], position[2]));
91
92            // ── Normal (placeholder when absent; recalculated below) ──────
93            let normal = if has_normals {
94                let n_base = idx * 3;
95                if n_base + 2 >= m.normals.len() {
96                    return Err(format!(
97                        "OBJ ({file_path}): normal index {idx} out of range \
98                         (normals.len={})",
99                        m.normals.len()
100                    ));
101                }
102                [
103                    m.normals[n_base],
104                    m.normals[n_base + 1],
105                    m.normals[n_base + 2],
106                ]
107            } else {
108                [0.0, 1.0, 0.0] // temporary; flat normals computed below
109            };
110
111            // ── UV ────────────────────────────────────────────────────────
112            let tex_coords = if has_texcoords {
113                let uv_base = idx * 2;
114                if uv_base + 1 >= m.texcoords.len() {
115                    return Err(format!(
116                        "OBJ ({file_path}): texcoord index {idx} out of range \
117                         (texcoords.len={})",
118                        m.texcoords.len()
119                    ));
120                }
121                // OBJ UV origin is bottom-left; flip V to match GPU convention.
122                [m.texcoords[uv_base], 1.0 - m.texcoords[uv_base + 1]]
123            } else {
124                [0.0, 0.0]
125            };
126
127            vertices.push(Vertex {
128                position,
129                normal,
130                tex_coords,
131                color: [1.0, 1.0, 1.0],
132                joint_indices: [0; 4],
133                joint_weights: [0.0; 4],
134            });
135        }
136
137        // Compute flat normals per-model, only when the model lacks them.
138        // This ensures models WITH normals are never touched.
139        if !has_normals {
140            let model_verts = &mut vertices[model_start..];
141            let remainder = compute_flat_normals_inplace(model_verts);
142            if remainder > 0 {
143                tracing::error!(
144                    "[AssetManager] WARN: '{file_path}' model '{}' has {remainder} \
145                     trailing vertices that don't form a complete triangle — \
146                     normals for those vertices left as Y-up.",
147                    model.name
148                );
149            }
150        }
151    }
152
153    Ok((vertices, aabb))
154}
155
156/// Compute flat (per-face) normals for a triangle-list vertex buffer in place.
157///
158/// Returns the number of leftover vertices that could not form a complete
159/// triangle (should be 0 for well-formed meshes).
160fn compute_flat_normals_inplace(vertices: &mut [Vertex]) -> usize {
161    let chunks = vertices.chunks_exact_mut(3);
162    let remainder_len = chunks.into_remainder().len(); // borrow ends here
163
164    for tri in vertices.chunks_exact_mut(3) {
165        let v0 = Vec3::from(tri[0].position);
166        let v1 = Vec3::from(tri[1].position);
167        let v2 = Vec3::from(tri[2].position);
168
169        let cross = (v1 - v0).cross(v2 - v0);
170        let normal = if cross.length_squared() > 1e-10 {
171            cross.normalize()
172        } else {
173            Vec3::Y // degenerate triangle → default up
174        };
175
176        let n = [normal.x, normal.y, normal.z];
177        tri[0].normal = n;
178        tri[1].normal = n;
179        tri[2].normal = n;
180    }
181
182    remainder_len
183}
184
185// ============================================================================
186//  AssetManager
187// ============================================================================
188
189pub struct AssetManager {
190    mesh_cache: std::collections::HashMap<String, Mesh>,
191    texture_cache: std::collections::HashMap<String, Arc<wgpu::BindGroup>>,
192    /// Lazily created magenta octahedron used while async loads are in flight.
193    placeholder_mesh: Option<Mesh>,
194
195    pub path_to_uuid: std::collections::HashMap<String, Uuid>,
196    pub uuid_to_path: std::collections::HashMap<Uuid, String>,
197    /// Assets whose bytes are baked into the binary (e.g. via `include_bytes!`).
198    pub embedded_assets: std::collections::HashMap<String, std::borrow::Cow<'static, [u8]>>,
199}
200
201impl Default for AssetManager {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207impl AssetManager {
208    pub fn new() -> Self {
209        let mut manager = Self {
210            mesh_cache: std::collections::HashMap::new(),
211            texture_cache: std::collections::HashMap::new(),
212            placeholder_mesh: None,
213            path_to_uuid: std::collections::HashMap::new(),
214            uuid_to_path: std::collections::HashMap::new(),
215            embedded_assets: std::collections::HashMap::new(),
216        };
217        manager.scan_assets_directory(Path::new("assets"));
218        manager
219    }
220
221    /// Serbest bırakılmış GPU kaynaklarını (mesh/texture) cache'ten siler.
222    /// Sadece referans sayısı 1'e düşmüş (yani ECS'te kullanılmayan ve 
223    /// sadece AssetManager'ın bildiği) varlıklar silinir.
224    pub fn garbage_collect(&mut self) -> usize {
225        let mut freed = 0;
226        
227        let initial_meshes = self.mesh_cache.len();
228        self.mesh_cache.retain(|key, mesh| {
229            if key.starts_with("primitive/") { return true; }
230            std::sync::Arc::strong_count(&mesh.vbuf) > 1
231        });
232        freed += initial_meshes - self.mesh_cache.len();
233
234        let initial_textures = self.texture_cache.len();
235        self.texture_cache.retain(|key, tex| {
236            if key.starts_with("primitive/") { return true; }
237            std::sync::Arc::strong_count(tex) > 1
238        });
239        freed += initial_textures - self.texture_cache.len();
240        
241        freed
242    }
243
244    // ── Path / UUID helpers ───────────────────────────────────────────────
245
246    /// Normalise a file-system path to forward-slash form for use as a map key.
247    ///
248    /// Uses [`Path`] to avoid platform-specific separator assumptions.
249    pub fn normalize_path(path: &str) -> String {
250        Path::new(path)
251            .components()
252            .map(|c| c.as_os_str().to_string_lossy().into_owned())
253            .collect::<Vec<_>>()
254            .join("/")
255    }
256
257    /// Return the UUID registered for `path`, if any.
258    pub fn get_uuid(&self, path: &str) -> Option<Uuid> {
259        self.path_to_uuid.get(&Self::normalize_path(path)).copied()
260    }
261
262    /// Return the filesystem path registered for `uuid`, if any.
263    pub fn get_path(&self, uuid: &Uuid) -> Option<String> {
264        self.uuid_to_path.get(uuid).cloned()
265    }
266
267    /// Resolve a load source to a filesystem path.
268    ///
269    /// If `source` parses as a UUID, the registered path is returned.
270    /// Otherwise `source` is normalised and returned as-is.
271    pub fn resolve_path_from_meta_source(&self, source: &str) -> Result<String, String> {
272        if let Ok(id) = Uuid::parse_str(source) {
273            self.get_path(&id)
274                .ok_or_else(|| format!("Missing UUID reference: {source}"))
275        } else {
276            Ok(Self::normalize_path(source))
277        }
278    }
279
280    /// Return a cached mesh by its source ID without triggering a load.
281    pub fn get_cached_mesh(&self, source_id: &str) -> Option<Mesh> {
282        self.mesh_cache.get(source_id).cloned()
283    }
284
285    /// Embed a raw asset byte slice under `path` so it can be loaded without
286    /// a filesystem read.
287    pub fn embed_asset(&mut self, path: &str, data: impl Into<std::borrow::Cow<'static, [u8]>>) {
288        self.embedded_assets
289            .insert(Self::normalize_path(path), data.into());
290    }
291
292    // ── Asset scanning ────────────────────────────────────────────────────
293
294    /// Recursively scan `dir` for known asset extensions, creating or
295    /// reading `.meta` sidecar files to assign stable UUIDs.
296    ///
297    /// Safe to call multiple times — existing entries are updated, not
298    /// duplicated.
299    pub fn scan_assets_directory(&mut self, dir: &Path) {
300        if !dir.is_dir() {
301            return;
302        }
303
304        let entries = match std::fs::read_dir(dir) {
305            Ok(e) => e,
306            Err(e) => {
307                tracing::error!(
308                    "[AssetManager] Cannot read directory {}: {e}",
309                    dir.display()
310                );
311                return;
312            }
313        };
314
315        for entry in entries.flatten() {
316            let path = entry.path();
317
318            if path.is_dir() {
319                self.scan_assets_directory(&path);
320                continue;
321            }
322
323            let is_asset = path
324                .extension()
325                .map(|ext| {
326                    matches!(
327                        ext.to_string_lossy().to_lowercase().as_str(),
328                        "obj"
329                            | "gltf"
330                            | "glb"
331                            | "png"
332                            | "jpg"
333                            | "jpeg"
334                            | "hdr"
335                            | "wav"
336                            | "mp3"
337                            | "ogg"
338                            | "ttf"
339                            | "otf"
340                            | "ron"
341                    )
342                })
343                .unwrap_or(false);
344
345            if !is_asset {
346                continue;
347            }
348
349            let meta_path = PathBuf::from(format!("{}.meta", path.display()));
350            let uuid = self.read_or_create_meta(&path, &meta_path);
351
352            let normalized = Self::normalize_path(&path.to_string_lossy());
353            self.path_to_uuid.insert(normalized.clone(), uuid);
354            self.uuid_to_path.insert(uuid, normalized);
355        }
356    }
357
358    /// Read an existing `.meta` file or create a new one, returning the UUID.
359    fn read_or_create_meta(&self, asset_path: &Path, meta_path: &Path) -> Uuid {
360        if meta_path.exists() {
361            match std::fs::read_to_string(meta_path)
362                .map_err(|e| e.to_string())
363                .and_then(|s| ron::from_str::<AssetMeta>(&s).map_err(|e| e.to_string()))
364            {
365                Ok(meta) => return meta.uuid,
366                Err(e) => {
367                    tracing::error!(
368                        "[AssetManager] WARN: corrupt .meta for '{}' ({e}). \
369                         Regenerating UUID — existing scene references to this \
370                         asset will break.",
371                        asset_path.display()
372                    );
373                    // Fall through to generate a fresh UUID.
374                }
375            }
376        }
377
378        let uuid = Uuid::new_v4();
379        let meta = AssetMeta { uuid };
380
381        match ron::ser::to_string_pretty(&meta, ron::ser::PrettyConfig::default()) {
382            Ok(ron_str) => {
383                if let Err(e) = std::fs::write(meta_path, ron_str) {
384                    tracing::error!(
385                        "[AssetManager] WARN: could not write .meta for '{}': {e}",
386                        asset_path.display()
387                    );
388                }
389            }
390            Err(e) => tracing::error!("[AssetManager] WARN: RON serialisation failed: {e}"),
391        }
392
393        uuid
394    }
395
396    // ── Placeholder mesh ──────────────────────────────────────────────────
397
398    /// Return (creating if needed) a small magenta octahedron used as a
399    /// stand-in while an async asset load is in flight.
400    pub fn loading_placeholder_mesh(&mut self, device: &wgpu::Device) -> Mesh {
401        if let Some(ref m) = self.placeholder_mesh {
402            return m.clone();
403        }
404        let m = Self::create_loading_placeholder(device);
405        self.placeholder_mesh = Some(m.clone());
406        m
407    }
408
409    fn create_loading_placeholder(device: &wgpu::Device) -> Mesh {
410        // Octahedron — recognisable from any angle, low vertex count.
411        const POSITIONS: [[f32; 3]; 6] = [
412            [1.0, 0.0, 0.0],  // +X
413            [-1.0, 0.0, 0.0], // -X
414            [0.0, 1.0, 0.0],  // +Y
415            [0.0, -1.0, 0.0], // -Y
416            [0.0, 0.0, 1.0],  // +Z
417            [0.0, 0.0, -1.0], // -Z
418        ];
419        const TRIANGLES: [[usize; 3]; 8] = [
420            [0, 2, 4],
421            [2, 1, 4],
422            [1, 3, 4],
423            [3, 0, 4],
424            [2, 0, 5],
425            [1, 2, 5],
426            [3, 1, 5],
427            [0, 3, 5],
428        ];
429        const COLOR: [f32; 3] = [0.95, 0.45, 0.95]; // magenta
430
431        let mut vertices = Vec::with_capacity(TRIANGLES.len() * 3);
432
433        for tri in &TRIANGLES {
434            for &i in tri {
435                let pos = POSITIONS[i];
436                let n = Vec3::new(pos[0], pos[1], pos[2]).normalize();
437                vertices.push(Vertex {
438                    position: pos,
439                    normal: [n.x, n.y, n.z],
440                    tex_coords: [0.0, 0.0],
441                    color: COLOR,
442                    joint_indices: [0; 4],
443                    joint_weights: [0.0; 4],
444                });
445            }
446        }
447
448        let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
449            label: Some("Async loading placeholder"),
450            contents: bytemuck::cast_slice(&vertices),
451            usage: wgpu::BufferUsages::VERTEX,
452        });
453
454        Mesh::new(
455            device,
456            Arc::new(vbuf),
457            &vertices,
458            Vec3::ZERO,
459            "__async_loading__".to_string(),
460        )
461    }
462}