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